From 10c9a13340b056274874406ea8daba4d2c61d737 Mon Sep 17 00:00:00 2001 From: Jaime Idolpx Date: Sat, 13 Jun 2026 22:10:31 -0400 Subject: [PATCH] Refactor DevicesPage to simplify device navigation and enhance DeviceDetailOverlay integration Enhance MediaEntry to support executable file types with appropriate icons Revamp SearchOverlay to utilize a locate database for improved search functionality and add database management features Update StatusPage to accommodate new DeviceDetailOverlay structure Implement locate-db module for efficient file searching and database handling Adjust Vite configuration to exclude sqlite-wasm from dependency optimization --- package.json | 2 + src/app/components/DeviceDetailOverlay.tsx | 689 ++++++++++----------- src/app/components/DevicesPage.tsx | 14 +- src/app/components/MediaEntry.tsx | 5 +- src/app/components/SearchOverlay.tsx | 176 +++--- src/app/components/StatusPage.tsx | 10 +- src/app/locate-db.ts | 110 ++++ vite.config.ts | 4 +- 8 files changed, 521 insertions(+), 489 deletions(-) create mode 100644 src/app/locate-db.ts diff --git a/package.json b/package.json index 63c3061..14d4ed5 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@radix-ui/react-toggle": "1.1.2", "@radix-ui/react-toggle-group": "1.1.2", "@radix-ui/react-tooltip": "1.1.8", + "@sqlite.org/sqlite-wasm": "^3.53.0-build1", "@types/parallax-js": "^3.1.3", "@types/react-syntax-highlighter": "^15.5.13", "@uiw/react-codemirror": "^4.25.10", @@ -80,6 +81,7 @@ "recharts": "2.15.2", "remark-gfm": "^4.0.1", "sonner": "2.0.3", + "swiper": "^12.2.0", "tailwind-merge": "3.2.0", "three": "^0.160.0", "tw-animate-css": "1.3.8", diff --git a/src/app/components/DeviceDetailOverlay.tsx b/src/app/components/DeviceDetailOverlay.tsx index c3a67d4..1bef674 100644 --- a/src/app/components/DeviceDetailOverlay.tsx +++ b/src/app/components/DeviceDetailOverlay.tsx @@ -1,13 +1,16 @@ import { useEffect, useRef, useState } from 'react'; import { SettingsInput } from './ui/settings-input'; -import { X, ChevronLeft, ChevronRight, Printer, HardDrive, Network, Box, FolderOpen, MoreVertical, Play, Pause, SkipForward, SkipBack, RotateCcw } from 'lucide-react'; +import { X, Printer, HardDrive, Network, Box, FolderOpen, MoreVertical, RotateCcw } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import type { Swiper as SwiperType } from 'swiper'; +import 'swiper/css'; import { toast } from 'sonner'; import { fileExists, getFileContents, joinPath, stat } from '../webdav'; import MediaBrowser from './MediaBrowser'; import MediaSet, { mediaSetEntryUrl, type MediaSetEntry } from './MediaSet'; -interface Device { +export interface Device { id: string; number: string; type: 'printer' | 'drive' | 'network' | 'other' | 'meatloaf'; @@ -20,130 +23,72 @@ interface Device { } interface DeviceDetailOverlayProps { - device: Device; + devices: Device[]; + initialIndex: number; config: any; setConfig: (config: any) => void; onClose: () => void; - onNavigate: (direction: 'prev' | 'next') => void; - hasPrev: boolean; - hasNext: boolean; + onIndexChange?: (index: number) => void; } -export default function DeviceDetailOverlay({ - device, - config, - setConfig, - onClose, - onNavigate, - hasPrev, - hasNext -}: DeviceDetailOverlayProps) { - const [touchStart, setTouchStart] = useState(0); - const [touchEnd, setTouchEnd] = useState(0); +function DeviceIcon({ device }: { device: Device }) { + const cls = `w-6 h-6`; + switch (device.type) { + case 'printer': return ; + case 'drive': return ; + case 'network': return ; + default: return ; + } +} + +// ── Per-device slide content ───────────────────────────────────────────────── + +interface DeviceCardProps { + device: Device; + config: any; + setConfig: (c: any) => void; + isActive: boolean; +} + +function DeviceCard({ device, config, setConfig, isActive }: DeviceCardProps) { const [browsingField, setBrowsingField] = useState<'url' | 'base_url' | 'cache' | null>(null); - const [showCommandMenu, setShowCommandMenu] = useState(false); - - const minSwipeDistance = 50; - - const onTouchStart = (e: React.TouchEvent) => { - setTouchEnd(0); - setTouchStart(e.targetTouches[0].clientX); - }; - - const onTouchMove = (e: React.TouchEvent) => { - setTouchEnd(e.targetTouches[0].clientX); - }; - - const onTouchEnd = () => { - if (!touchStart || !touchEnd) return; - - const distance = touchStart - touchEnd; - const isLeftSwipe = distance > minSwipeDistance; - const isRightSwipe = distance < -minSwipeDistance; - - if (isLeftSwipe && hasNext) { - onNavigate('next'); - } - if (isRightSwipe && hasPrev) { - onNavigate('prev'); - } - }; + const [mediaSetFiles, setMediaSetFiles] = useState(null); + const detectTokenRef = useRef(0); + // Close any open browser when swiped away. useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - onClose(); - } else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { - const tag = (document.activeElement as HTMLElement)?.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; - if (e.key === 'ArrowLeft' && hasPrev) onNavigate('prev'); - if (e.key === 'ArrowRight' && hasNext) onNavigate('next'); - } - }; + if (!isActive) setBrowsingField(null); + }, [isActive]); - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [hasPrev, hasNext, onClose, onNavigate]); - - const updateDeviceSetting = (path: string[], value: any) => { - const newConfig = JSON.parse(JSON.stringify(config)); - let current = newConfig; - - for (let i = 0; i < path.length - 1; i++) { - current = current[path[i]]; - } - - current[path[path.length - 1]] = value; - setConfig(newConfig); - }; - - const getDevicePath = (): string[] => { - return ['devices', 'iec', device.number]; - }; + const getDevicePath = (): string[] => ['devices', 'iec', device.number]; const getDeviceData = () => { - const path = getDevicePath(); - let current = config; - for (const key of path) { - current = current?.[key]; - } + let current: any = config; + for (const key of getDevicePath()) current = current?.[key]; return current || {}; }; const deviceData = getDeviceData(); - const getDeviceIcon = (type: Device['type']) => { - switch (type) { - case 'printer': - return ; - case 'drive': - return ; - case 'network': - return ; - default: - return ; - } + const updateDeviceSetting = (path: string[], value: any) => { + const newConfig = JSON.parse(JSON.stringify(config)); + let current = newConfig; + for (let i = 0; i < path.length - 1; i++) current = current[path[i]]; + current[path[path.length - 1]] = value; + setConfig(newConfig); }; - const [mediaSetFiles, setMediaSetFiles] = useState(null); - const detectTokenRef = useRef(0); - - // Cancel any in-flight detection and reset when navigating to a different device. useEffect(() => { ++detectTokenRef.current; setMediaSetFiles(null); }, [device.number]); - // Sync config-backed media_set to display state whenever it is set explicitly - // (e.g. after a playlist is mounted). useEffect(() => { if (Array.isArray(deviceData.media_set) && deviceData.media_set.length > 1) { setMediaSetFiles(deviceData.media_set as MediaSetEntry[]); } }, [deviceData.media_set]); - // Pattern-detect sibling numbered files for the given URL. Called only when - // the user actively sets a URL — never on overlay load or device switch. const detectMediaSet = async (url: string) => { const token = ++detectTokenRef.current; setMediaSetFiles(null); @@ -152,23 +97,18 @@ export default function DeviceDetailOverlay({ const [, prefix, , ext] = match; const candidates: string[] = []; for (let i = 1; i <= 10; i++) candidates.push(`${prefix}${i}${ext}`); - const flags = await Promise.all( - candidates.map(f => stat(f).then(r => r !== null).catch(() => false)) - ); + const flags = await Promise.all(candidates.map(f => stat(f).then(r => r !== null).catch(() => false))); if (detectTokenRef.current !== token) return; const found = candidates.filter((_, i) => flags[i]); setMediaSetFiles(found.length > 1 ? found : null); }; - const switchMedia = (file: string) => { - const path = getDevicePath(); - updateDeviceSetting([...path, 'url'], file); - }; + const switchMedia = (file: string) => updateDeviceSetting([...getDevicePath(), 'url'], file); const isOutsideBase = (url: string, baseUrl: string): boolean => { if (!baseUrl || !url.startsWith('/')) return false; - const normalizedBase = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'; - return url !== baseUrl && !url.startsWith(normalizedBase); + const nb = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'; + return url !== baseUrl && !url.startsWith(nb); }; const clearBaseAndCache = (dev: any) => { @@ -183,15 +123,14 @@ export default function DeviceDetailOverlay({ const isVms = selExt === 'vms'; try { const text = await (await getFileContents(selectedPath)).text(); - const dir = selectedPath.split('/').slice(0, -1).join('/') || '/'; + const dir = selectedPath.split('/').slice(0, -1).join('/') || '/'; const candidates: MediaSetEntry[] = text.split('\n') - .map(l => l.trim()) - .filter(l => l.length > 0 && !l.startsWith('#')) + .map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('#')) .map(l => { if (isVms) { const comma = l.indexOf(','); if (comma > 0) { - const url = l.slice(0, comma).trim(); + const url = l.slice(0, comma).trim(); const name = l.slice(comma + 1).trim(); const resolved = url.startsWith('/') ? url : joinPath(dir, url); return name ? { url: resolved, name } : resolved; @@ -204,14 +143,13 @@ export default function DeviceDetailOverlay({ const files = candidates.filter((_, i) => existsArr[i]); if (files.length === 0) { toast.error('No files in swap list exist on device'); return; } if (files.length < 2) { toast.error('A media set needs more than one item'); return; } - if (files.length < candidates.length) { + if (files.length < candidates.length) toast.warning(`${candidates.length - files.length} missing file(s) skipped from swap list`); - } const newConfig = JSON.parse(JSON.stringify(config)); let dev = newConfig; for (const k of devicePath) dev = dev[k]; if (isOutsideBase(mediaSetEntryUrl(files[0]), dev.base_url || '')) clearBaseAndCache(dev); - dev.url = mediaSetEntryUrl(files[0]); + dev.url = mediaSetEntryUrl(files[0]); dev.media_set = files; setConfig(newConfig); } catch (e: any) { @@ -229,11 +167,222 @@ export default function DeviceDetailOverlay({ } }; - const sendCommand = (command: string) => { - console.log(`Sending command to device ${device.number}: ${command}`); - // In a real app, this would send the command to the device - setShowCommandMenu(false); - }; + return ( + <> +
+
+ {!device.physical && ( +
+ + +
+ )} + +
+ +
+ {device.type.charAt(0).toUpperCase() + device.type.slice(1)} +
+
+ +
+ + updateDeviceSetting([...getDevicePath(), 'name'], v)} + onClear={() => updateDeviceSetting([...getDevicePath(), 'name'], '')} + className="px-3 py-2 border border-neutral-300 rounded-lg" + containerClassName="w-full" + /> +
+ + {!device.physical && <> +
+ +
+ updateDeviceSetting([...getDevicePath(), 'base_url'], v)} + onClear={() => updateDeviceSetting([...getDevicePath(), 'base_url'], '')} + containerClassName="flex-1" + className="px-3 py-2 border border-neutral-300 rounded-lg" + /> + +
+
+ +
+ +
+ { + const devicePath = getDevicePath(); + const newConfig = JSON.parse(JSON.stringify(config)); + let dev = newConfig; + for (const k of devicePath) dev = dev[k]; + if (isOutsideBase(newUrl, dev.base_url || '')) clearBaseAndCache(dev); + dev.url = newUrl; + delete dev.media_set; + setConfig(newConfig); + if (newUrl) void detectMediaSet(newUrl); + }} + onClear={() => { + const devicePath = getDevicePath(); + const newConfig = JSON.parse(JSON.stringify(config)); + let dev = newConfig; + for (const k of devicePath) dev = dev[k]; + delete dev.url; + delete dev.media_set; + setConfig(newConfig); + ++detectTokenRef.current; + setMediaSetFiles(null); + }} + containerClassName="flex-1" + className="px-3 py-2 border border-neutral-300 rounded-lg" + /> + +
+ {mediaSetFiles && ( +
+ +
+ )} +
+ + {(deviceData.cache !== undefined || + (deviceData.base_url ?? '').includes('://') || + (deviceData.url ?? '').includes('://')) && ( +
+ +
+ updateDeviceSetting([...getDevicePath(), 'cache'], v)} + onClear={() => updateDeviceSetting([...getDevicePath(), 'cache'], '')} + containerClassName="flex-1" + className="px-3 py-2 border border-neutral-300 rounded-lg" + /> + +
+
+ )} + } + + {deviceData.mode !== undefined && ( +
+ + {device.physical + ? {(deviceData.mode ?? 0) === 0 ? 'Read Only' : 'Write Enabled'} + :
+ {([0, 1] as const).map((val, i) => ( + + ))} +
+ } +
+ )} + + {deviceData.baud !== undefined && ( +
+ + updateDeviceSetting([...getDevicePath(), 'baud'], parseInt(v))} + className="w-full px-3 py-2 border border-neutral-300 rounded-lg" + /> +
+ )} +
+ +
+

Device ID

+ {device.id} +
+
+ + {browsingField && ( + { + if (browsingField === 'url') { + void handleFileSelect(selectedPath); + } else { + updateDeviceSetting([...getDevicePath(), browsingField], selectedPath); + } + setBrowsingField(null); + }} + onClose={() => setBrowsingField(null)} + /> + )} + + ); +} + +// ── Overlay shell with Swiper ──────────────────────────────────────────────── + +export default function DeviceDetailOverlay({ + devices, + initialIndex, + config, + setConfig, + onClose, + onIndexChange, +}: DeviceDetailOverlayProps) { + const [activeIndex, setActiveIndex] = useState(initialIndex); + const [showCommandMenu, setShowCommandMenu] = useState(false); + const swiperRef = useRef(null); + + const activeDevice = devices[activeIndex] ?? devices[0]; + + useEffect(() => { setShowCommandMenu(false); }, [activeIndex]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { onClose(); return; } + const tag = (document.activeElement as HTMLElement)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if (e.key === 'ArrowLeft') swiperRef.current?.slidePrev(); + if (e.key === 'ArrowRight') swiperRef.current?.slideNext(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [onClose]); return ( @@ -249,40 +398,47 @@ export default function DeviceDetailOverlay({ animate={{ y: 0 }} exit={{ y: '100%' }} transition={{ type: 'spring', damping: 30, stiffness: 300 }} - className="fixed inset-0 bg-white overflow-y-auto z-50" + className="fixed inset-0 bg-white flex flex-col z-50" onClick={(e) => e.stopPropagation()} - onTouchStart={onTouchStart} - onTouchMove={onTouchMove} - onTouchEnd={onTouchEnd} > -
+ {/* ── Header ── */} +
+
- {device.number} - {getDeviceIcon(device.type)} - {device.physical && ( + {activeDevice.number} + + {activeDevice.physical && ( Physical )}
- + +
+ {devices.length > 1 && ( + + {activeIndex + 1} / {devices.length} + + )} + +
{showCommandMenu && ( -
+
)} - -
- - Swipe to navigate - -
-
-
- {!device.physical && ( -
- - -
- )} - -
- -
- {device.type.charAt(0).toUpperCase() + device.type.slice(1)} -
-
- -
- - { - const path = getDevicePath(); - updateDeviceSetting([...path, 'name'], v); - }} - onClear={() => updateDeviceSetting([...getDevicePath(), 'name'], '')} - className="px-3 py-2 border border-neutral-300 rounded-lg" - containerClassName="w-full" - /> -
- - {!device.physical && <> -
- -
- { - const path = getDevicePath(); - updateDeviceSetting([...path, 'base_url'], v); - }} - onClear={() => updateDeviceSetting([...getDevicePath(), 'base_url'], '')} - containerClassName="flex-1" - className="px-3 py-2 border border-neutral-300 rounded-lg" - /> - -
-
- -
- -
- { - const devicePath = getDevicePath(); - const newConfig = JSON.parse(JSON.stringify(config)); - let dev = newConfig; - for (const k of devicePath) dev = dev[k]; - if (isOutsideBase(newUrl, dev.base_url || '')) clearBaseAndCache(dev); - dev.url = newUrl; - delete dev.media_set; - setConfig(newConfig); - if (newUrl) void detectMediaSet(newUrl); - }} - onClear={() => { - const devicePath = getDevicePath(); - const newConfig = JSON.parse(JSON.stringify(config)); - let dev = newConfig; - for (const k of devicePath) dev = dev[k]; - delete dev.url; - delete dev.media_set; - setConfig(newConfig); - ++detectTokenRef.current; - setMediaSetFiles(null); - }} - containerClassName="flex-1" - className="px-3 py-2 border border-neutral-300 rounded-lg" - /> - -
- - {mediaSetFiles && ( -
- -
- )} -
- - {(deviceData.cache !== undefined || - (deviceData.base_url ?? '').includes('://') || - (deviceData.url ?? '').includes('://')) && ( -
- -
- { - const path = getDevicePath(); - updateDeviceSetting([...path, 'cache'], v); - }} - onClear={() => updateDeviceSetting([...getDevicePath(), 'cache'], '')} - containerClassName="flex-1" - className="px-3 py-2 border border-neutral-300 rounded-lg" - /> - -
-
- )} - } - - {deviceData.mode !== undefined && ( -
- - {device.physical - ? - {(deviceData.mode ?? 0) === 0 ? 'Read Only' : 'Write Enabled'} - - :
- {([0, 1] as const).map((val, i) => ( - - ))} -
- } -
- )} - - {deviceData.baud !== undefined && ( -
- - { - const path = getDevicePath(); - updateDeviceSetting([...path, 'baud'], parseInt(v)); - }} - className="w-full px-3 py-2 border border-neutral-300 rounded-lg" - /> -
- )} -
- -
-

Device ID

- - {device.id} - -
-
- - {browsingField && ( - { - if (browsingField === 'url') { - void handleFileSelect(selectedPath); - } else { - const devPath = getDevicePath(); - updateDeviceSetting([...devPath, browsingField], selectedPath); - } - setBrowsingField(null); + {/* ── Swiper ── */} +
+ { swiperRef.current = s; }} + onSlideChange={(s) => { + setActiveIndex(s.activeIndex); + onIndexChange?.(s.activeIndex); }} - onClose={() => setBrowsingField(null)} - /> - )} + touchStartPreventDefault={false} + style={{ height: '100%' }} + > + {devices.map((device, i) => ( + + + + ))} + +
diff --git a/src/app/components/DevicesPage.tsx b/src/app/components/DevicesPage.tsx index bea98ae..086989e 100644 --- a/src/app/components/DevicesPage.tsx +++ b/src/app/components/DevicesPage.tsx @@ -201,13 +201,6 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp const handleCloseOverlay = () => setSelectedDeviceIndex(null); - const handleNavigate = (direction: 'prev' | 'next') => { - if (selectedDeviceIndex === null) return; - if (direction === 'prev' && selectedDeviceIndex > 0) - setSelectedDeviceIndex(selectedDeviceIndex - 1); - else if (direction === 'next' && selectedDeviceIndex < displayDevices.length - 1) - setSelectedDeviceIndex(selectedDeviceIndex + 1); - }; const { send: wsSend } = useWs(); @@ -414,13 +407,12 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp {selectedDeviceIndex !== null && ( 0} - hasNext={selectedDeviceIndex < displayDevices.length - 1} + onIndexChange={setSelectedDeviceIndex} /> )} diff --git a/src/app/components/MediaEntry.tsx b/src/app/components/MediaEntry.tsx index f687b8c..c20b5d6 100644 --- a/src/app/components/MediaEntry.tsx +++ b/src/app/components/MediaEntry.tsx @@ -21,6 +21,7 @@ import { Save, SlidersHorizontal, Terminal, + AppWindow, } from 'lucide-react'; import { humanFileSize, type EntryInfo } from '../webdav'; @@ -42,6 +43,7 @@ export const DISC_EXTS = new Set(['iso', 'img', 'cue']); export const HD_EXTS = new Set(['d1m', 'd2m', 'd4m', 'd90', 'dhd', 'hdd', 'bbt', 'd8b', 'dfi']); export const ARCHIVE_EXTS = new Set(['zip', '7z', 'tar', 'gz', 'bz2', 'xz', 'rar', 'arj', 'lzh', 'ace', 'z', 'lha', 'cab', 'lbr', 'arc', 'ark', 'lnx']); export const COMIC_EXTS = new Set(['cbz', 'cbr', 'cb7', 'cbt', 'cbz']); +export const EXEC_EXTS = new Set(['exe', 'com', 'bat', 'cmd', 'msi', 'app', 'apk', 'bin', 'run', 'sh', 'vbs', 'ps1']); export const CONFIG_EXTS = new Set(['config']); // ─── EntryIcon ──────────────────────────────────────────────────────────────── @@ -65,7 +67,8 @@ export function EntryIcon({ entry }: { entry: EntryInfo }) { if (DOC_EXTS.has(ext)) return ; if (CODE_EXTS.has(ext)) return ; if (TEXT_EXTS.has(ext)) return ; - if (COMIC_EXTS.has(ext)) return ; + if (COMIC_EXTS.has(ext)) return ; + if (EXEC_EXTS.has(ext)) return ; return ; } diff --git a/src/app/components/SearchOverlay.tsx b/src/app/components/SearchOverlay.tsx index 94866d8..a59360b 100644 --- a/src/app/components/SearchOverlay.tsx +++ b/src/app/components/SearchOverlay.tsx @@ -1,12 +1,16 @@ -import { useState } from 'react'; -import { X, Search, HardDrive, Loader2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { flushSync } from 'react-dom'; +import { X, Search, HardDrive, Loader2, RefreshCw } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { toast } from 'sonner'; +import { humanFileSize } from '../webdav'; import { - humanFileSize, - listDirectory, - type EntryInfo, -} from '../webdav'; + openLocateDb, + searchLocate, + isLocateDbLoaded, + resetLocateDb, + type LocateEntry, +} from '../locate-db'; interface SearchOverlayProps { config: any; @@ -28,16 +32,14 @@ const HARDWARE_FILE_EXTS = new Set([ function fileExtension(p: string): string { const dot = p.lastIndexOf('.'); - if (dot < 0) return ''; - return p.slice(dot + 1).toLowerCase(); + return dot < 0 ? '' : p.slice(dot + 1).toLowerCase(); } -function detectType(entry: EntryInfo): string { - if (entry.type === 'folder') return 'DIR'; - const ext = fileExtension(entry.name); - if (!ext) return 'FILE'; - if (HARDWARE_FILE_EXTS.has(ext)) return ext.toUpperCase(); - return ext.toUpperCase(); +function entryToResult(e: LocateEntry): SearchResult { + if (e.is_dir) return { name: e.name, path: e.path, type: 'DIR', size: e.size, sizeText: '—' }; + const ext = fileExtension(e.name); + const type = ext ? (HARDWARE_FILE_EXTS.has(ext) ? ext.toUpperCase() : ext.toUpperCase()) : 'FILE'; + return { name: e.name, path: e.path, type, size: e.size, sizeText: humanFileSize(e.size) }; } export default function SearchOverlay({ config, setConfig, onClose }: SearchOverlayProps) { @@ -50,75 +52,61 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver const [hasSearched, setHasSearched] = useState(false); const [showDeviceMenu, setShowDeviceMenu] = useState(null); const [searchError, setSearchError] = useState(null); + const [dbBytes, setDbBytes] = useState(null); + const [dbPhase, setDbPhase] = useState<'idle' | 'downloading' | 'ready'>('idle'); + + void systemType; void videoStandard; void language; + + // Show "ready" phase if DB was already loaded from a previous search. + useEffect(() => { + if (isLocateDbLoaded()) setDbPhase('ready'); + }, []); const handleSearch = async () => { - if (!query.trim()) { - toast.error('Please enter a search term'); - return; - } + if (!query.trim()) { toast.error('Please enter a search term'); return; } setIsSearching(true); setHasSearched(true); setResults([]); setSearchError(null); try { - const found: SearchResult[] = []; - const needle = query.trim().toLowerCase(); - const max = 500; // safety cap - - // BFS through the WebDAV tree. - const queue: string[] = ['/']; - const seen = new Set(); - while (queue.length > 0 && found.length < max) { - const dir = queue.shift()!; - if (seen.has(dir)) continue; - seen.add(dir); - let items; - try { - items = await listDirectory(dir); - } catch { - // Skip directories we cannot read (permission/404/etc.) and keep going. - continue; - } - for (const it of items) { - if (it.type === 'folder') { - queue.push(it.path); - continue; - } - if ( - it.name.toLowerCase().includes(needle) || - it.path.toLowerCase().includes(needle) - ) { - found.push({ - name: it.name, - path: it.path, - type: detectType(it), - size: it.size, - sizeText: humanFileSize(it.size), - }); - } - } + if (!isLocateDbLoaded()) { + setDbPhase('downloading'); + setDbBytes(null); + await openLocateDb(bytes => flushSync(() => setDbBytes(bytes))); + setDbPhase('ready'); } - // Sort results: closest match by name first, then by path length, then alpha. - found.sort((a, b) => { - const an = a.name.toLowerCase(); - const bn = b.name.toLowerCase(); - const aStarts = an.startsWith(needle) ? 0 : 1; - const bStarts = bn.startsWith(needle) ? 0 : 1; - if (aStarts !== bStarts) return aStarts - bStarts; - if (a.path.length !== b.path.length) return a.path.length - b.path.length; - return an.localeCompare(bn); + const needle = query.trim(); + const entries = searchLocate(needle); + + // Sort: name starts with query first, then by path depth (shorter = closer to root). + const lower = needle.toLowerCase(); + entries.sort((a, b) => { + const aStart = a.name.toLowerCase().startsWith(lower) ? 0 : 1; + const bStart = b.name.toLowerCase().startsWith(lower) ? 0 : 1; + if (aStart !== bStart) return aStart - bStart; + return a.path.length - b.path.length; }); - setResults(found); + setResults(entries.map(entryToResult)); } catch (e: any) { - setSearchError((e && e.message) || 'Search failed'); + setSearchError(e?.message ?? 'Search failed'); + resetLocateDb(); + setDbPhase('idle'); } finally { setIsSearching(false); } }; + const handleRefreshDb = async () => { + resetLocateDb(); + setDbPhase('idle'); + setHasSearched(false); + setResults([]); + toast.info('Database will be reloaded on next search'); + }; + const handleMount = (deviceNum: string, result: SearchResult) => { const newConfig = JSON.parse(JSON.stringify(config)); if (newConfig.devices?.iec?.[deviceNum]) { @@ -142,12 +130,12 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver return devices; }; - // Suppress unused-var warnings for fields the UI exposes but doesn't yet - // map to a real filter (the WebDAV server doesn't carry these as metadata). - void systemType; void videoStandard; void language; - const availableDevices = getAvailableDevices(); + const loadingLabel = dbPhase === 'downloading' + ? (dbBytes === null ? 'Loading database…' : `Loading database… ${humanFileSize(dbBytes)}`) + : 'Searching…'; + return (

Search

- +
+ {dbPhase === 'ready' && ( + + )} + +
@@ -198,11 +197,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
- setSystemType(e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg text-sm"> @@ -212,11 +207,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
- setVideoStandard(e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg text-sm"> @@ -224,11 +215,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
- setLanguage(e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg text-sm"> @@ -241,14 +228,12 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver {isSearching && (
-
Searching…
+
{loadingLabel}
)} {!isSearching && searchError && ( -
- Search failed: {searchError} -
+
Search failed: {searchError}
)} {!isSearching && !searchError && hasSearched && ( @@ -295,9 +280,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver ))} {availableDevices.length === 0 && ( -
- No enabled devices -
+
No enabled devices
)}
)} @@ -317,7 +300,10 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver {!hasSearched && (
-
Enter a search term to find files on the WebDAV server
+
Enter a search term to find files on the device
+ {dbPhase === 'ready' && ( +
Database loaded
+ )}
)}
diff --git a/src/app/components/StatusPage.tsx b/src/app/components/StatusPage.tsx index 6750b83..34b61ec 100644 --- a/src/app/components/StatusPage.tsx +++ b/src/app/components/StatusPage.tsx @@ -249,21 +249,19 @@ export default function StatusPage({ config, setConfig, onOpenFileManager }: Sta {showDeviceOverlay && ( setShowDeviceOverlay(false)} - onNavigate={() => {}} - hasPrev={false} - hasNext={false} /> )} diff --git a/src/app/locate-db.ts b/src/app/locate-db.ts new file mode 100644 index 0000000..f8744f8 --- /dev/null +++ b/src/app/locate-db.ts @@ -0,0 +1,110 @@ +import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; +import { getWebDAVBaseUrl, basename } from './webdav'; + +const LOCATE_PATH = '/sd/.locate'; + +// Memoize the module init — loading the WASM binary is expensive. +let _sqlite3Promise: Promise | null = null; +function getSqlite3(): Promise { + if (!_sqlite3Promise) { + _sqlite3Promise = sqlite3InitModule({ print: () => {}, printErr: () => {} }); + } + return _sqlite3Promise; +} + +let _db: any | null = null; + +export function isLocateDbLoaded(): boolean { + return _db !== null; +} + +export function resetLocateDb(): void { + try { _db?.close(); } catch { /* ignore */ } + _db = null; +} + +/** + * Fetch /sd/.locate and open it as an in-memory SQLite database. + * Calling again when already loaded is a no-op unless you call resetLocateDb() first. + * onProgress receives raw bytes received so far during the download. + */ +export async function openLocateDb(onProgress?: (bytes: number) => void): Promise { + if (_db) return; + + const sqlite3 = await getSqlite3(); + + const url = getWebDAVBaseUrl() + LOCATE_PATH; + const response = await fetch(url); + if (!response.ok) throw new Error(`Cannot fetch locate database: ${response.status} ${response.statusText}`); + + // Stream the response body so onProgress gets called per chunk. + let bytes: Uint8Array; + if (response.body) { + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.byteLength; + onProgress?.(received); + } + const combined = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; } + bytes = combined; + } else { + bytes = new Uint8Array(await response.arrayBuffer()); + } + + // Allocate the bytes in WASM heap, then deserialize into a fresh in-memory DB. + const p = sqlite3.wasm.allocFromTypedArray(bytes); + const db = new sqlite3.oo1.DB(':memory:', 'ct'); + const rc = sqlite3.capi.sqlite3_deserialize( + db.pointer, + 'main', + p, + bytes.length, + bytes.length, + 1 | 2, // SQLITE_DESERIALIZE_FREEONCLOSE | SQLITE_DESERIALIZE_RESIZEABLE + ); + if (rc !== 0) { + db.close(); + throw new Error(`sqlite3_deserialize failed (code ${rc})`); + } + _db = db; +} + +export interface LocateEntry { + path: string; + name: string; + size: number; + mtime: number; + is_dir: boolean; +} + +/** + * Run a case-insensitive substring search against the loaded locate database. + * openLocateDb() must have been called (and resolved) before calling this. + */ +export function searchLocate(query: string, limit = 500): LocateEntry[] { + if (!_db) throw new Error('Locate database is not loaded'); + const needle = `%${query}%`; + const rows: LocateEntry[] = []; + _db.exec({ + sql: 'SELECT path, size, mtime, is_dir FROM files WHERE path LIKE ? LIMIT ?', + bind: [needle, limit], + rowMode: 'array', + callback: (row: any[]) => { + rows.push({ + path: row[0] as string, + name: basename(row[0] as string) || (row[0] as string), + size: row[1] as number, + mtime: row[2] as number, + is_dir: (row[3] as number) !== 0, + }); + }, + }); + return rows; +} diff --git a/vite.config.ts b/vite.config.ts index 7adbf69..8101f5f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,10 +27,12 @@ export default defineConfig({ ], resolve: { alias: { - // Alias @ to the src directory '@': path.resolve(__dirname, './src'), }, }, + optimizeDeps: { + exclude: ['@sqlite.org/sqlite-wasm'], + }, // File types to support raw imports. Never add .css, .tsx, or .ts files to this. assetsInclude: ['**/*.svg', '**/*.csv'],