From 45b23e32c8adc42eb80297ced29d018d7be21965 Mon Sep 17 00:00:00 2001 From: Alex Patin Date: Tue, 24 Feb 2026 22:45:41 -0500 Subject: [PATCH 1/4] feat: improve file selection UX with shift-click range select and click-away deselect --- packages/web/src/components/FileBrowser.tsx | 166 ++++++++++-------- packages/web/src/components/FileGrid.tsx | 43 ++++- packages/web/src/components/FileList.tsx | 66 ++++--- packages/web/src/components/VolumePicker.tsx | 170 +++++++++++++++++++ packages/web/src/hooks/useSelection.ts | 20 ++- packages/web/src/pages/index.astro | 3 +- 6 files changed, 369 insertions(+), 99 deletions(-) create mode 100644 packages/web/src/components/VolumePicker.tsx diff --git a/packages/web/src/components/FileBrowser.tsx b/packages/web/src/components/FileBrowser.tsx index db34a70..0f21933 100644 --- a/packages/web/src/components/FileBrowser.tsx +++ b/packages/web/src/components/FileBrowser.tsx @@ -102,11 +102,27 @@ function FileBrowserInner() { const { selected, toggle: toggleSelection, + selectRange, selectAll, clear: clearSelection, count: selectionCount, + lastToggled, } = useSelection() + const fileAreaRef = useRef(null) + + useEffect(() => { + if (selectionCount === 0) return + const onClick = (e: MouseEvent) => { + if (fileAreaRef.current?.contains(e.target as Node)) return + // Don't clear if clicking inside the bulk action bar + if ((e.target as HTMLElement).closest('[data-bulk-actions]')) return + clearSelection() + } + window.addEventListener('click', onClick) + return () => window.removeEventListener('click', onClick) + }, [selectionCount, clearSelection]) + // Global drag-and-drop: show overlay when files are dragged anywhere on page const [globalDragging, setGlobalDragging] = useState(false) const dragCounter = useRef(0) @@ -245,13 +261,13 @@ function FileBrowserInner() { } return ( -
+
0 ? 'pb-20' : ''}`}> {/* Header */}
- +
{user?.role === 'admin' && ( @@ -365,78 +381,85 @@ function FileBrowserInner() {
)} - {/* Bulk Action Bar */} - {selectionCount > 0 && ( -
- {selectionCount} selected -
- - - + }} + className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md bg-white border border-gray-200 hover:bg-gray-50 text-gray-700 shadow-sm transition-colors" + > + + Download + + + +
- )} +
{/* Content */} +
{!volume ? ( ) : isLoading ? ( @@ -466,6 +489,8 @@ function FileBrowserInner() { selectable selected={selected} onToggle={toggleSelection} + onShiftSelect={selectRange} + lastToggled={lastToggled} onSelectAll={(paths) => (paths.length > 0 ? selectAll(paths) : clearSelection())} /> ) : ( @@ -478,8 +503,11 @@ function FileBrowserInner() { selectable selected={selected} onToggle={toggleSelection} + onShiftSelect={selectRange} + lastToggled={lastToggled} /> )} +
{/* Preview Modal */} {previewFile && ( setPreviewFile(null)} /> diff --git a/packages/web/src/components/FileGrid.tsx b/packages/web/src/components/FileGrid.tsx index 8cf3703..4bf3691 100644 --- a/packages/web/src/components/FileGrid.tsx +++ b/packages/web/src/components/FileGrid.tsx @@ -1,5 +1,5 @@ import { Folder } from 'lucide-react' -import { useCallback, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import type { FileEntry } from '../lib/api' import { api } from '../lib/api' import { getFileIcon, hasThumbnailSupport } from '../lib/fileIcons' @@ -22,6 +22,8 @@ interface FileGridProps { selectable?: boolean selected?: Set onToggle?: (path: string) => void + onShiftSelect?: (paths: string[]) => void + lastToggled?: string | null } function FileGridItem({ @@ -32,7 +34,7 @@ function FileGridItem({ onContextMenu, selectable, isSelected, - onToggle, + onCheckboxClick, }: { entry: FileEntry volume: string @@ -41,7 +43,7 @@ function FileGridItem({ onContextMenu?: (file: FileEntry, x: number, y: number) => void selectable?: boolean isSelected?: boolean - onToggle?: (path: string) => void + onCheckboxClick?: (path: string, shiftKey: boolean) => void }) { const longPressTimer = useRef | null>(null) const [thumbError, setThumbError] = useState(false) @@ -79,13 +81,18 @@ function FileGridItem({
{selectable && (
{ if (e.shiftKey) e.preventDefault() }} + onClick={(e) => { + e.stopPropagation() + onCheckboxClick?.(entry.path, e.shiftKey) + }} > onToggle?.(entry.path)} - className="rounded border-gray-300 text-brand-600 focus:ring-brand-500" + readOnly + className="rounded border-gray-300 text-brand-600 focus:ring-brand-500 pointer-events-none" />
)} @@ -104,7 +111,7 @@ function FileGridItem({ onTouchMove={handleTouchEnd} className={`w-full flex flex-col items-center gap-1.5 sm:gap-2 p-2 sm:p-3 rounded-lg hover:bg-gray-100 transition-colors group text-center ${isSelected ? 'bg-blue-50 ring-2 ring-brand-200' : ''}`} > -
+
{showThumb ? ( { + if (shiftKey && lastToggled && onShiftSelect) { + const currentIndex = entries.findIndex((e) => e.path === path) + const lastIndex = entries.findIndex((e) => e.path === lastToggled) + if (currentIndex !== -1 && lastIndex !== -1) { + const start = Math.min(currentIndex, lastIndex) + const end = Math.max(currentIndex, lastIndex) + const rangePaths = entries.slice(start, end + 1).map((e) => e.path) + onShiftSelect(rangePaths) + return + } + } + onToggle?.(path) + }, + [entries, lastToggled, onShiftSelect, onToggle], + ) + if (entries.length === 0) { return } @@ -151,7 +178,7 @@ export default function FileGrid({ onContextMenu={onContextMenu} selectable={selectable} isSelected={selected?.has(entry.path)} - onToggle={onToggle} + onCheckboxClick={handleCheckboxClick} /> ))}
diff --git a/packages/web/src/components/FileList.tsx b/packages/web/src/components/FileList.tsx index fda6ed2..3356f4b 100644 --- a/packages/web/src/components/FileList.tsx +++ b/packages/web/src/components/FileList.tsx @@ -44,6 +44,8 @@ interface FileListProps { selectable?: boolean selected?: Set onToggle?: (path: string) => void + onShiftSelect?: (paths: string[]) => void + lastToggled?: string | null onSelectAll?: (paths: string[]) => void } @@ -57,7 +59,7 @@ function FileListRow({ onContextMenu, selectable, isSelected, - onToggle, + onCheckboxClick, }: { entry: FileEntry volume: string @@ -68,7 +70,7 @@ function FileListRow({ onContextMenu?: (file: FileEntry, x: number, y: number) => void selectable?: boolean isSelected?: boolean - onToggle?: (path: string) => void + onCheckboxClick?: (path: string, shiftKey: boolean) => void }) { const longPressTimer = useRef | null>(null) const [thumbError, setThumbError] = useState(false) @@ -118,16 +120,19 @@ function FileListRow({ onTouchMove={handleTouchEnd} > {selectable && ( - + { if (e.shiftKey) e.preventDefault() }} + onClick={(e) => { + e.stopPropagation() + onCheckboxClick?.(entry.path, e.shiftKey) + }} + > { - e.stopPropagation() - onToggle?.(entry.path) - }} - onClick={(e) => e.stopPropagation()} - className="rounded border-gray-300 text-brand-600 focus:ring-brand-500" + readOnly + className="rounded border-gray-300 text-brand-600 focus:ring-brand-500 pointer-events-none" /> )} @@ -220,6 +225,8 @@ export default function FileList({ selectable, selected, onToggle, + onShiftSelect, + lastToggled, onSelectAll, }: FileListProps) { const [sortField, setSortField] = useState('name') @@ -257,6 +264,24 @@ export default function FileList({ return [...dirs.sort(compare), ...files.sort(compare)] }, [entries, sortField, sortDir]) + const handleCheckboxClick = useCallback( + (path: string, shiftKey: boolean) => { + if (shiftKey && lastToggled && onShiftSelect) { + const currentIndex = sortedEntries.findIndex((e) => e.path === path) + const lastIndex = sortedEntries.findIndex((e) => e.path === lastToggled) + if (currentIndex !== -1 && lastIndex !== -1) { + const start = Math.min(currentIndex, lastIndex) + const end = Math.max(currentIndex, lastIndex) + const rangePaths = sortedEntries.slice(start, end + 1).map((e) => e.path) + onShiftSelect(rangePaths) + return + } + } + onToggle?.(path) + }, + [sortedEntries, lastToggled, onShiftSelect, onToggle], + ) + if (entries.length === 0) { return } @@ -278,18 +303,21 @@ export default function FileList({ {selectable && ( - + { + if (allSelected) { + onSelectAll?.([]) + } else { + onSelectAll?.(entries.map((e) => e.path)) + } + }} + > { - if (allSelected) { - onSelectAll?.([]) - } else { - onSelectAll?.(entries.map((e) => e.path)) - } - }} - className="rounded border-gray-300 text-brand-600 focus:ring-brand-500" + readOnly + className="rounded border-gray-300 text-brand-600 focus:ring-brand-500 pointer-events-none" /> )} @@ -339,7 +367,7 @@ export default function FileList({ onContextMenu={onContextMenu} selectable={selectable} isSelected={selected?.has(entry.path)} - onToggle={onToggle} + onCheckboxClick={handleCheckboxClick} /> ))} diff --git a/packages/web/src/components/VolumePicker.tsx b/packages/web/src/components/VolumePicker.tsx new file mode 100644 index 0000000..12d30f0 --- /dev/null +++ b/packages/web/src/components/VolumePicker.tsx @@ -0,0 +1,170 @@ +import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' +import { HardDrive, Loader2, LogOut, Settings, Shield } from 'lucide-react' +import { useAuth } from '../hooks/useAuth' +import { api, type VolumeInfo } from '../lib/api' +import { withBase } from '../lib/basePath' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 30_000, retry: 1 }, + }, +}) + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}` +} + +function VolumeTile({ volume }: { volume: VolumeInfo }) { + const usagePercent = volume.totalBytes > 0 ? (volume.usedBytes / volume.totalBytes) * 100 : 0 + const barColor = usagePercent > 90 ? 'bg-red-500' : usagePercent > 75 ? 'bg-amber-500' : 'bg-brand-500' + + return ( + +
+
+ +
+

+ {volume.label} +

+
+
+
+
+
+

+ {formatBytes(volume.usedBytes)} used of {formatBytes(volume.totalBytes)} +

+
+
+ ) +} + +function VolumePickerInner() { + const { isAuthenticated, isLoading: authLoading, user, logout } = useAuth() + + const { data, isLoading } = useQuery({ + queryKey: ['volumes'], + queryFn: () => api.getVolumes(), + enabled: isAuthenticated, + }) + + if (authLoading) { + return ( +
+ +
+ ) + } + + if (!isAuthenticated) { + if (typeof window !== 'undefined') window.location.href = withBase('/login') + return null + } + + const volumes = data?.volumes || [] + + // Single volume — redirect straight into it + if (!isLoading && volumes.length === 1) { + if (typeof window !== 'undefined') { + window.location.replace(withBase(`/volumes/${encodeURIComponent(volumes[0].id)}`)) + } + return ( +
+ +
+ ) + } + + return ( +
+ {/* Header */} +
+
+ MiniNAS +

MiniNAS

+
+
+ {user?.role === 'admin' && ( + + + + )} + + + + +
+
+ + {/* Content */} + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static placeholder tiles +
+
+
+
+
+
+
+
+ ))} +
+ ) : volumes.length === 0 ? ( +
+ +

No volumes available

+ {user?.role === 'admin' && ( +

+ Add volumes in the{' '} + + admin panel + +

+ )} +
+ ) : ( + <> +

Volumes

+
+ {volumes.map((v) => ( + + ))} +
+ + )} +
+ ) +} + +export default function VolumePicker() { + return ( + + + + ) +} diff --git a/packages/web/src/hooks/useSelection.ts b/packages/web/src/hooks/useSelection.ts index 9b2430c..35a0a0e 100644 --- a/packages/web/src/hooks/useSelection.ts +++ b/packages/web/src/hooks/useSelection.ts @@ -1,7 +1,8 @@ -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' export function useSelection() { const [selected, setSelected] = useState>(new Set()) + const lastToggled = useRef(null) const toggle = useCallback((path: string) => { setSelected((prev) => { @@ -13,6 +14,20 @@ export function useSelection() { } return next }) + lastToggled.current = path + }, []) + + const selectRange = useCallback((paths: string[]) => { + setSelected((prev) => { + const next = new Set(prev) + for (const p of paths) { + next.add(p) + } + return next + }) + if (paths.length > 0) { + lastToggled.current = paths[paths.length - 1] + } }, []) const selectAll = useCallback((paths: string[]) => { @@ -21,9 +36,10 @@ export function useSelection() { const clear = useCallback(() => { setSelected(new Set()) + lastToggled.current = null }, []) const isSelected = useCallback((path: string) => selected.has(path), [selected]) - return { selected, toggle, selectAll, clear, isSelected, count: selected.size } + return { selected, toggle, selectRange, selectAll, clear, isSelected, count: selected.size, lastToggled: lastToggled.current } } diff --git a/packages/web/src/pages/index.astro b/packages/web/src/pages/index.astro index b5953f1..47a2d94 100644 --- a/packages/web/src/pages/index.astro +++ b/packages/web/src/pages/index.astro @@ -1,7 +1,8 @@ --- import Layout from "../layouts/Layout.astro"; +import VolumePicker from "../components/VolumePicker"; --- - + From 5b6c7a82eb07119cc7052a133fe5748abcce8b4f Mon Sep 17 00:00:00 2001 From: Alex Patin Date: Tue, 24 Feb 2026 22:51:41 -0500 Subject: [PATCH 2/4] Added customizable app name --- .env.example | 3 +++ packages/api/src/config.ts | 1 + packages/api/src/middleware/static.ts | 6 ++++-- packages/web/src/components/AdminPanel.tsx | 3 ++- packages/web/src/components/FileBrowser.tsx | 5 +++-- packages/web/src/components/PasskeyLogin.tsx | 5 +++-- packages/web/src/components/PasskeySetup.tsx | 7 ++++--- packages/web/src/components/UpdateSection.tsx | 5 +++-- packages/web/src/components/VolumePicker.tsx | 5 +++-- packages/web/src/components/WebDAVTokens.tsx | 7 ++++--- packages/web/src/lib/appName.ts | 7 +++++++ 11 files changed, 37 insertions(+), 17 deletions(-) create mode 100644 packages/web/src/lib/appName.ts diff --git a/.env.example b/.env.example index 0b2f994..80ff47a 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,6 @@ RP_ORIGIN=http://localhost:4321 # Upload staging directory (default: ~/.mininas/data/uploads) # UPLOAD_STAGING_DIR=~/.mininas/data/uploads + +# Custom display name shown in the UI header and page titles (default: MiniNAS) +# APP_NAME=MiniNAS diff --git a/packages/api/src/config.ts b/packages/api/src/config.ts index 3a450e8..73be2bb 100644 --- a/packages/api/src/config.ts +++ b/packages/api/src/config.ts @@ -63,4 +63,5 @@ export const config = { webDistDir: process.env.WEB_DIST_DIR || '', basePath: normalizeBasePath(process.env.BASE_PATH || ''), version: process.env.MININAS_VERSION || 'dev', + appName: process.env.APP_NAME || 'MiniNAS', } as const diff --git a/packages/api/src/middleware/static.ts b/packages/api/src/middleware/static.ts index 2811ce5..a27f9f0 100644 --- a/packages/api/src/middleware/static.ts +++ b/packages/api/src/middleware/static.ts @@ -86,10 +86,12 @@ export function createStaticMiddleware(distDir: string) { const raw = fs.readFileSync(filePath) const cacheControl = ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable' - // For HTML files: inject __BASE_PATH__ and rewrite asset references + // For HTML files: inject __BASE_PATH__ / __APP_NAME__ and rewrite asset references if (ext === '.html') { let html = raw.toString('utf-8') - html = html.replace('', ``) + const appName = config.appName + html = html.replace('', ``) + html = html.replace(/MiniNAS(.*?)<\/title>/g, `<title>${appName}$1`) if (bp) { // Rewrite Astro-generated asset references html = html.replace(/href="\//g, `href="${bp}/`) diff --git a/packages/web/src/components/AdminPanel.tsx b/packages/web/src/components/AdminPanel.tsx index 4b5707c..4b62706 100644 --- a/packages/web/src/components/AdminPanel.tsx +++ b/packages/web/src/components/AdminPanel.tsx @@ -36,6 +36,7 @@ import { } from '../hooks/useAdmin' import { useAuth } from '../hooks/useAuth' import type { AdminVolume } from '../lib/api' +import { APP_NAME } from '../lib/appName' import { withBase } from '../lib/basePath' import UpdateSection from './UpdateSection' import Badge from './ui/Badge' @@ -362,7 +363,7 @@ function VolumesSection() { const handleRemove = (vol: AdminVolume) => { if ( confirm( - `Remove volume "${vol.label}" (${vol.id})? This only removes it from MiniNAS — files on disk are not deleted.`, + `Remove volume "${vol.label}" (${vol.id})? This only removes it from ${APP_NAME} — files on disk are not deleted.`, ) ) { removeMutation.mutate(vol.id) diff --git a/packages/web/src/components/FileBrowser.tsx b/packages/web/src/components/FileBrowser.tsx index 0f21933..b6afe5d 100644 --- a/packages/web/src/components/FileBrowser.tsx +++ b/packages/web/src/components/FileBrowser.tsx @@ -23,6 +23,7 @@ import { useToast } from '../hooks/useToast' import { useUpload } from '../hooks/useUpload' import type { FileEntry } from '../lib/api' import { api } from '../lib/api' +import { APP_NAME } from '../lib/appName' import { BASE_PATH, withBase } from '../lib/basePath' import { getFilesFromDataTransfer } from '../lib/drop' import Breadcrumbs from './Breadcrumbs' @@ -265,8 +266,8 @@ function FileBrowserInner() { {/* Header */}
- MiniNAS -

MiniNAS

+ {APP_NAME} +

{APP_NAME}

diff --git a/packages/web/src/components/PasskeyLogin.tsx b/packages/web/src/components/PasskeyLogin.tsx index e725ae5..1be8c2e 100644 --- a/packages/web/src/components/PasskeyLogin.tsx +++ b/packages/web/src/components/PasskeyLogin.tsx @@ -1,5 +1,6 @@ import { Fingerprint, Loader2 } from 'lucide-react' import { useState } from 'react' +import { APP_NAME } from '../lib/appName' import { withBase } from '../lib/basePath' import { authenticatePasskey } from '../lib/passkeys' @@ -28,8 +29,8 @@ export default function PasskeyLogin() {
- MiniNAS -

MiniNAS

+ {APP_NAME} +

{APP_NAME}

Sign in with your passkey

diff --git a/packages/web/src/components/PasskeySetup.tsx b/packages/web/src/components/PasskeySetup.tsx index c3958c6..95ef676 100644 --- a/packages/web/src/components/PasskeySetup.tsx +++ b/packages/web/src/components/PasskeySetup.tsx @@ -1,5 +1,6 @@ import { CheckCircle, Fingerprint, Loader2, ShieldX } from 'lucide-react' import { useEffect, useState } from 'react' +import { APP_NAME } from '../lib/appName' import { withBase } from '../lib/basePath' import { checkSetupNeeded, registerPasskey } from '../lib/passkeys' @@ -51,7 +52,7 @@ export default function PasskeySetup() {
- MiniNAS + {APP_NAME}

Setup Unavailable

An admin account has already been configured. Setup can only be run once during @@ -83,8 +84,8 @@ export default function PasskeySetup() {

- MiniNAS -

Welcome to MiniNAS

+ {APP_NAME} +

Welcome to {APP_NAME}

Set up your passkey to secure your NAS. This will use your device's biometric authentication (Touch ID, Face ID, etc.) diff --git a/packages/web/src/components/UpdateSection.tsx b/packages/web/src/components/UpdateSection.tsx index 689df97..2b3b300 100644 --- a/packages/web/src/components/UpdateSection.tsx +++ b/packages/web/src/components/UpdateSection.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery } from '@tanstack/react-query' import { Loader2, Package, RefreshCw } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { api } from '../lib/api' +import { APP_NAME } from '../lib/appName' import Button from './ui/Button' export default function UpdateSection() { @@ -72,7 +73,7 @@ export default function UpdateSection() { ) : (

diff --git a/packages/web/src/components/VolumePicker.tsx b/packages/web/src/components/VolumePicker.tsx index 12d30f0..9a12e6f 100644 --- a/packages/web/src/components/VolumePicker.tsx +++ b/packages/web/src/components/VolumePicker.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-quer import { HardDrive, Loader2, LogOut, Settings, Shield } from 'lucide-react' import { useAuth } from '../hooks/useAuth' import { api, type VolumeInfo } from '../lib/api' +import { APP_NAME } from '../lib/appName' import { withBase } from '../lib/basePath' const queryClient = new QueryClient({ @@ -88,8 +89,8 @@ function VolumePickerInner() { {/* Header */}
- MiniNAS -

MiniNAS

+ {APP_NAME} +

{APP_NAME}

{user?.role === 'admin' && ( diff --git a/packages/web/src/components/WebDAVTokens.tsx b/packages/web/src/components/WebDAVTokens.tsx index 0e9a844..bd8b419 100644 --- a/packages/web/src/components/WebDAVTokens.tsx +++ b/packages/web/src/components/WebDAVTokens.tsx @@ -7,6 +7,7 @@ import { useRevokeWebDAVToken, useWebDAVTokens, } from '../hooks/useWebDAVTokens' +import { APP_NAME } from '../lib/appName' import { withBase } from '../lib/basePath' import Tabs from './ui/Tabs' @@ -81,7 +82,7 @@ function SetupGuide({ serverUrl, username }: { serverUrl: string; username?: str Connect to Server (⌘K)
  • Paste the server URL above and click Connect
  • -
  • Enter your MiniNAS username and the generated token as the password
  • +
  • Enter your {APP_NAME} username and the generated token as the password
  • )} {osTab === 'windows' && ( @@ -93,7 +94,7 @@ function SetupGuide({ serverUrl, username }: { serverUrl: string; username?: str
  • Paste the server URL in the Folder field
  • Check "Connect using different credentials"
  • -
  • Enter your MiniNAS username and the generated token as the password
  • +
  • Enter your {APP_NAME} username and the generated token as the password
  • )} {osTab === 'linux' && ( @@ -110,7 +111,7 @@ function SetupGuide({ serverUrl, username }: { serverUrl: string; username?: str sudo mount -t davfs {serverUrl} /mnt/mininas -
  • Enter your MiniNAS username and the generated token as the password
  • +
  • Enter your {APP_NAME} username and the generated token as the password
  • )}
    diff --git a/packages/web/src/lib/appName.ts b/packages/web/src/lib/appName.ts new file mode 100644 index 0000000..7874ff5 --- /dev/null +++ b/packages/web/src/lib/appName.ts @@ -0,0 +1,7 @@ +declare global { + interface Window { + __APP_NAME__?: string + } +} + +export const APP_NAME: string = typeof window !== 'undefined' ? (window.__APP_NAME__ ?? 'MiniNAS') : 'MiniNAS' From 7514d6714caa54ca3619d1a862a20fe45c6ae8af Mon Sep 17 00:00:00 2001 From: Alex Patin Date: Tue, 24 Feb 2026 23:00:02 -0500 Subject: [PATCH 3/4] Added customizable app name --- packages/web/astro.config.mjs | 25 +++++ packages/web/src/components/FileBrowser.tsx | 102 ++++++++++--------- packages/web/src/components/FileGrid.tsx | 6 +- packages/web/src/components/FileList.tsx | 4 +- packages/web/src/components/VolumePicker.tsx | 12 ++- packages/web/src/hooks/useSelection.ts | 11 +- packages/web/src/layouts/Layout.astro | 6 +- packages/web/src/lib/appName.server.ts | 25 +++++ packages/web/src/lib/appName.ts | 9 +- 9 files changed, 143 insertions(+), 57 deletions(-) create mode 100644 packages/web/src/lib/appName.server.ts diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index e0b2e42..d37f669 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -1,8 +1,30 @@ +import fs from 'node:fs' import http from 'node:http' +import os from 'node:os' +import path from 'node:path' import react from '@astrojs/react' import tailwind from '@astrojs/tailwind' import { defineConfig } from 'astro/config' +function loadAppName() { + // Read from ~/.mininas/config.json, then .env (same priority as API config) + let name = 'MiniNAS' + try { + const cfg = JSON.parse( + fs.readFileSync(path.join(os.homedir(), '.mininas', 'config.json'), 'utf-8'), + ) + if (cfg.APP_NAME) name = cfg.APP_NAME + } catch {} + try { + const env = fs.readFileSync(path.resolve(import.meta.dirname, '../../.env'), 'utf-8') + const match = env.match(/^APP_NAME=(.+)$/m) + if (match) name = match[1].trim() + } catch {} + // Real env var wins + if (process.env.APP_NAME) name = process.env.APP_NAME + return name +} + // Rewrite /volumes/X/Y/Z to /volumes so the catch-all [...path].astro // page is served for all sub-paths (used in both dev and preview). function volumesFallback(req, _res, next) { @@ -17,6 +39,9 @@ export default defineConfig({ integrations: [react(), tailwind()], server: { port: 4321, host: '0.0.0.0' }, vite: { + define: { + __APP_NAME_DEFAULT__: JSON.stringify(loadAppName()), + }, plugins: [ { name: 'volumes-spa-fallback', diff --git a/packages/web/src/components/FileBrowser.tsx b/packages/web/src/components/FileBrowser.tsx index b6afe5d..88eae9e 100644 --- a/packages/web/src/components/FileBrowser.tsx +++ b/packages/web/src/components/FileBrowser.tsx @@ -265,7 +265,10 @@ function FileBrowserInner() {
    0 ? 'pb-20' : ''}`}> {/* Header */}
    - + {APP_NAME}

    {APP_NAME}

    @@ -386,7 +389,10 @@ function FileBrowserInner() {
    0 ? 'translate-y-0' : 'translate-y-full'}`} > -
    +
    {selectionCount} selected @@ -461,53 +467,53 @@ function FileBrowserInner() { {/* Content */}
    - {!volume ? ( - - ) : isLoading ? ( - viewMode === 'list' ? ( - + {!volume ? ( + + ) : isLoading ? ( + viewMode === 'list' ? ( + + ) : ( + + ) + ) : error ? ( +
    + Error loading files: {(error as Error).message} +
    + ) : viewMode === 'list' ? ( + + deleteMutation.mutate(path, { + onSuccess: () => addToast('success', 'File deleted'), + onError: (err) => addToast('error', `Delete failed: ${(err as Error).message}`), + }) + } + onPreview={setPreviewFile} + onShare={setShareFile} + onContextMenu={handleContextMenu} + selectable + selected={selected} + onToggle={toggleSelection} + onShiftSelect={selectRange} + lastToggled={lastToggled} + onSelectAll={(paths) => (paths.length > 0 ? selectAll(paths) : clearSelection())} + /> ) : ( - - ) - ) : error ? ( -
    - Error loading files: {(error as Error).message} -
    - ) : viewMode === 'list' ? ( - - deleteMutation.mutate(path, { - onSuccess: () => addToast('success', 'File deleted'), - onError: (err) => addToast('error', `Delete failed: ${(err as Error).message}`), - }) - } - onPreview={setPreviewFile} - onShare={setShareFile} - onContextMenu={handleContextMenu} - selectable - selected={selected} - onToggle={toggleSelection} - onShiftSelect={selectRange} - lastToggled={lastToggled} - onSelectAll={(paths) => (paths.length > 0 ? selectAll(paths) : clearSelection())} - /> - ) : ( - - )} + + )}
    {/* Preview Modal */} {previewFile && ( diff --git a/packages/web/src/components/FileGrid.tsx b/packages/web/src/components/FileGrid.tsx index 4bf3691..7a0065d 100644 --- a/packages/web/src/components/FileGrid.tsx +++ b/packages/web/src/components/FileGrid.tsx @@ -1,5 +1,5 @@ import { Folder } from 'lucide-react' -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import type { FileEntry } from '../lib/api' import { api } from '../lib/api' import { getFileIcon, hasThumbnailSupport } from '../lib/fileIcons' @@ -82,7 +82,9 @@ function FileGridItem({ {selectable && (
    { if (e.shiftKey) e.preventDefault() }} + onMouseDown={(e) => { + if (e.shiftKey) e.preventDefault() + }} onClick={(e) => { e.stopPropagation() onCheckboxClick?.(entry.path, e.shiftKey) diff --git a/packages/web/src/components/FileList.tsx b/packages/web/src/components/FileList.tsx index 3356f4b..6670f5c 100644 --- a/packages/web/src/components/FileList.tsx +++ b/packages/web/src/components/FileList.tsx @@ -122,7 +122,9 @@ function FileListRow({ {selectable && ( { if (e.shiftKey) e.preventDefault() }} + onMouseDown={(e) => { + if (e.shiftKey) e.preventDefault() + }} onClick={(e) => { e.stopPropagation() onCheckboxClick?.(entry.path, e.shiftKey) diff --git a/packages/web/src/components/VolumePicker.tsx b/packages/web/src/components/VolumePicker.tsx index 9a12e6f..409a273 100644 --- a/packages/web/src/components/VolumePicker.tsx +++ b/packages/web/src/components/VolumePicker.tsx @@ -21,7 +21,8 @@ function formatBytes(bytes: number): string { function VolumeTile({ volume }: { volume: VolumeInfo }) { const usagePercent = volume.totalBytes > 0 ? (volume.usedBytes / volume.totalBytes) * 100 : 0 - const barColor = usagePercent > 90 ? 'bg-red-500' : usagePercent > 75 ? 'bg-amber-500' : 'bg-brand-500' + const barColor = + usagePercent > 90 ? 'bg-red-500' : usagePercent > 75 ? 'bg-amber-500' : 'bg-brand-500' return (
    -
    +

    {formatBytes(volume.usedBytes)} used of {formatBytes(volume.totalBytes)} @@ -150,7 +154,9 @@ function VolumePickerInner() {

    ) : ( <> -

    Volumes

    +

    + Volumes +

    {volumes.map((v) => ( diff --git a/packages/web/src/hooks/useSelection.ts b/packages/web/src/hooks/useSelection.ts index 35a0a0e..6c8e4da 100644 --- a/packages/web/src/hooks/useSelection.ts +++ b/packages/web/src/hooks/useSelection.ts @@ -41,5 +41,14 @@ export function useSelection() { const isSelected = useCallback((path: string) => selected.has(path), [selected]) - return { selected, toggle, selectRange, selectAll, clear, isSelected, count: selected.size, lastToggled: lastToggled.current } + return { + selected, + toggle, + selectRange, + selectAll, + clear, + isSelected, + count: selected.size, + lastToggled: lastToggled.current, + } } diff --git a/packages/web/src/layouts/Layout.astro b/packages/web/src/layouts/Layout.astro index c516d69..f779ea7 100644 --- a/packages/web/src/layouts/Layout.astro +++ b/packages/web/src/layouts/Layout.astro @@ -1,9 +1,13 @@ --- +import { getAppName } from '../lib/appName.server'; + interface Props { title: string; } +const appName = getAppName(); const { title } = Astro.props; +const resolvedTitle = title.replaceAll('MiniNAS', appName); --- @@ -19,7 +23,7 @@ const { title } = Astro.props; - {title} + {resolvedTitle} diff --git a/packages/web/src/lib/appName.server.ts b/packages/web/src/lib/appName.server.ts new file mode 100644 index 0000000..ac61539 --- /dev/null +++ b/packages/web/src/lib/appName.server.ts @@ -0,0 +1,25 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +let cached: string | null = null + +export function getAppName(): string { + if (cached) return cached + let name = 'MiniNAS' + try { + const cfg = JSON.parse( + fs.readFileSync(path.join(os.homedir(), '.mininas', 'config.json'), 'utf-8'), + ) + if (cfg.APP_NAME) name = cfg.APP_NAME + } catch {} + try { + const root = path.resolve(import.meta.dirname, '../../../../.env') + const env = fs.readFileSync(root, 'utf-8') + const match = env.match(/^APP_NAME=(.+)$/m) + if (match) name = match[1].trim() + } catch {} + if (process.env.APP_NAME) name = process.env.APP_NAME + cached = name + return name +} diff --git a/packages/web/src/lib/appName.ts b/packages/web/src/lib/appName.ts index 7874ff5..fe82cc2 100644 --- a/packages/web/src/lib/appName.ts +++ b/packages/web/src/lib/appName.ts @@ -4,4 +4,11 @@ declare global { } } -export const APP_NAME: string = typeof window !== 'undefined' ? (window.__APP_NAME__ ?? 'MiniNAS') : 'MiniNAS' +// In dev: Vite replaces __APP_NAME_DEFAULT__ at compile time via `define`. +// In prod: the API static middleware injects window.__APP_NAME__ at serve time. +declare const __APP_NAME_DEFAULT__: string + +export const APP_NAME: string = + typeof window !== 'undefined' + ? (window.__APP_NAME__ ?? __APP_NAME_DEFAULT__) + : __APP_NAME_DEFAULT__ From a6641cb3ecee7dcb7f3c5465a2f89bbaf42b20bc Mon Sep 17 00:00:00 2001 From: Alex Patin Date: Tue, 24 Feb 2026 23:01:07 -0500 Subject: [PATCH 4/4] Fixed lint issues --- packages/api/src/middleware/static.ts | 5 ++++- packages/web/astro.config.mjs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/api/src/middleware/static.ts b/packages/api/src/middleware/static.ts index a27f9f0..33cf3cc 100644 --- a/packages/api/src/middleware/static.ts +++ b/packages/api/src/middleware/static.ts @@ -90,7 +90,10 @@ export function createStaticMiddleware(distDir: string) { if (ext === '.html') { let html = raw.toString('utf-8') const appName = config.appName - html = html.replace('', ``) + html = html.replace( + '', + ``, + ) html = html.replace(/MiniNAS(.*?)<\/title>/g, `<title>${appName}$1`) if (bp) { // Rewrite Astro-generated asset references diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index d37f669..4ab16e3 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -28,7 +28,7 @@ function loadAppName() { // Rewrite /volumes/X/Y/Z to /volumes so the catch-all [...path].astro // page is served for all sub-paths (used in both dev and preview). function volumesFallback(req, _res, next) { - if (req.url && req.url.startsWith('/volumes/') && !req.url.includes('.')) { + if (req.url?.startsWith('/volumes/') && !req.url.includes('.')) { req.url = '/volumes' } next()