diff --git a/src/app/components/SearchOverlay.tsx b/src/app/components/SearchOverlay.tsx index a099825..bb7319d 100644 --- a/src/app/components/SearchOverlay.tsx +++ b/src/app/components/SearchOverlay.tsx @@ -1,9 +1,10 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { flushSync } from 'react-dom'; -import { X, Search, Loader2, RefreshCw, FolderSearch, File, Folder } from 'lucide-react'; +import { X, Search, Loader2, RefreshCw, FolderSearch, File, Folder, SlidersHorizontal, ArrowUp, ArrowDown, ArrowUpDown, HardDrive } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { toast } from 'sonner'; import { humanFileSize } from '../webdav'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; import { openLocateDb, searchLocate, @@ -27,21 +28,77 @@ interface SearchResult { size: number; sizeText: string; isDir: boolean; + system: string | null; + video: string | null; + language: string | null; } const DISK_EXTS = new Set(['d64','d71','d81','d82','dnp','t64','tap','g64','nib']); const CART_EXTS = new Set(['crt','bin']); const PRG_EXTS = new Set(['prg','p00']); +// Common ISO 639-1 codes seen in TOSEC / No-Intro filenames +const LANG_CODES = ['En','De','Fr','Es','It','Nl','Sv','Da','Fi','Pt','Pl','Ru','Ja','Ko','Zh','Cs','Hu','No']; +const LANG_NAMES: Record = { + En:'English', De:'German', Fr:'French', Es:'Spanish', It:'Italian', + Nl:'Dutch', Sv:'Swedish', Da:'Danish', Fi:'Finnish', Pt:'Portuguese', + Pl:'Polish', Ru:'Russian', Ja:'Japanese', Ko:'Korean', Zh:'Chinese', + Cs:'Czech', Hu:'Hungarian', No:'Norwegian', +}; +const LANG_RE = new RegExp( + '\\((' + LANG_CODES.join('|') + ')(?:[,+](?:' + LANG_CODES.join('|') + '))*\\)', + 'i' +); + +const VIDEO_RE = /\b(PAL(?:[_-]?(?:60|NTSC))?|NTSC(?:[_-]?PAL)?)\b/i; +const VIDEO_NORM: Record = { + 'PAL': 'PAL', 'PAL60': 'PAL60', 'PAL-60': 'PAL60', 'PAL_60': 'PAL60', + 'PAL-NTSC': 'PAL/NTSC', 'PAL_NTSC': 'PAL/NTSC', 'NTSC-PAL': 'PAL/NTSC', 'NTSC_PAL': 'PAL/NTSC', + 'NTSC': 'NTSC', +}; + +const SYSTEM_RE = /\b(C[-_]?64|C[-_]?128|VIC[-_]?20|Plus\/?4|C[-_]?16|PET|C[-_]?64DTV)\b/i; +const SYSTEM_NORM: Record = { + 'C64':'C64','C-64':'C64','C_64':'C64', + 'C128':'C128','C-128':'C128','C_128':'C128', + 'VIC20':'VIC-20','VIC-20':'VIC-20','VIC_20':'VIC-20', + 'PLUS4':'Plus/4','PLUS/4':'Plus/4', + 'C16':'C16','C-16':'C16','C_16':'C16', + 'PET':'PET', + 'C64DTV':'C64DTV','C-64DTV':'C64DTV', +}; + function fileExtension(p: string): string { const dot = p.lastIndexOf('.'); return dot < 0 ? '' : p.slice(dot + 1).toLowerCase(); } +function parseTags(path: string): { system: string | null; video: string | null; language: string | null } { + const videoM = path.match(VIDEO_RE); + const video = videoM ? (VIDEO_NORM[videoM[1].toUpperCase().replace(/[-_]/g, '-')] ?? videoM[1].toUpperCase()) : null; + + const sysM = path.match(SYSTEM_RE); + const system = sysM ? (SYSTEM_NORM[sysM[1].toUpperCase().replace(/[-_]/g, '')] ?? sysM[1]) : null; + + const langM = path.match(LANG_RE); + const language = langM ? (LANG_NAMES[langM[1]] ?? langM[1]) : null; + + return { system, video, language }; +} + function entryToResult(e: LocateEntry): SearchResult { - if (e.is_dir) return { name: e.name, path: e.path, type: 'DIR', size: 0, sizeText: '—', isDir: true }; + const tags = parseTags(e.path); + if (e.is_dir) { + return { name: e.name, path: e.path, type: 'DIR', size: 0, sizeText: '—', isDir: true, ...tags }; + } const ext = fileExtension(e.name); - return { name: e.name, path: e.path, type: ext ? ext.toUpperCase() : 'FILE', size: e.size, sizeText: humanFileSize(e.size), isDir: false }; + return { + name: e.name, path: e.path, + type: ext ? ext.toUpperCase() : 'FILE', + size: e.size, sizeText: humanFileSize(e.size), + isDir: false, + ...tags, + }; } function TypeBadge({ type, isDir }: { type: string; isDir: boolean }) { @@ -59,6 +116,34 @@ function scanPhaseLabel(phase: ScanPhase, value: number): string { return `Saving… ${humanFileSize(value)}`; } +type SortField = 'name' | 'size'; +type SortDir = 'asc' | 'desc'; + +function FilterChips({ + label, values, selected, onSelect, +}: { + label: string; + values: string[]; + selected: string | null; + onSelect: (v: string | null) => void; +}) { + if (values.length === 0) return null; + return ( +
+ {label} + {values.map(v => ( + + ))} +
+ ); +} + export default function SearchOverlay({ config, setConfig, onClose }: SearchOverlayProps) { const [query, setQuery] = useState(''); const [isSearching, setIsSearching] = useState(false); @@ -67,10 +152,16 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver const [scanValue, setScanValue] = useState(0); const [results, setResults] = useState([]); const [hasSearched, setHasSearched] = useState(false); - const [showDeviceMenu, setShowDeviceMenu] = useState(null); + const [mountEntry, setMountEntry] = useState(null); const [searchError, setSearchError] = useState(null); const [dbBytes, setDbBytes] = useState(null); const [dbPhase, setDbPhase] = useState<'idle' | 'downloading' | 'ready'>('idle'); + const [showFilter, setShowFilter] = useState(false); + const [filterSystem, setFilterSystem] = useState(null); + const [filterVideo, setFilterVideo] = useState(null); + const [filterLanguage, setFilterLanguage] = useState(null); + const [sortField, setSortField] = useState('name'); + const [sortDir, setSortDir] = useState('asc'); useEffect(() => { if (isLocateDbLoaded()) setDbPhase('ready'); @@ -80,8 +171,10 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver if (!query.trim()) { toast.error('Please enter a search term'); return; } setIsSearching(true); setHasSearched(true); - setResults([]); setSearchError(null); + setFilterSystem(null); + setFilterVideo(null); + setFilterLanguage(null); try { if (!isLocateDbLoaded()) { setDbPhase('downloading'); @@ -91,11 +184,14 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver } const needle = query.trim(); const entries = searchLocate(needle); + const hasWildcard = /[*?]/.test(needle); entries.sort((a, b) => { - const lower = needle.toLowerCase(); - const aStart = a.name.toLowerCase().startsWith(lower) ? 0 : 1; - const bStart = b.name.toLowerCase().startsWith(lower) ? 0 : 1; - if (aStart !== bStart) return aStart - bStart; + if (!hasWildcard) { + const lower = needle.toLowerCase(); + 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(entries.map(entryToResult)); @@ -134,32 +230,62 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver toast.info('Database will be reloaded on next search'); }; - const handleMount = (deviceNum: string, result: SearchResult) => { + const mountOnDevice = (deviceType: 'drive' | 'meatloaf', key: string) => { + if (!mountEntry) return; const newConfig = JSON.parse(JSON.stringify(config)); - if (newConfig.devices?.iec?.[deviceNum]) { - newConfig.devices.iec[deviceNum].url = result.path; - setConfig(newConfig); - toast.success(`Mounted ${result.name} on Device #${deviceNum}`); - setShowDeviceMenu(null); - } + if (!newConfig.devices) newConfig.devices = {}; + if (!newConfig.devices.iec) newConfig.devices.iec = {}; + if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType }; + const dev = newConfig.devices.iec[key]; + dev.url = mountEntry.path; + delete dev.media_set; + if (!dev.enabled) dev.enabled = 1; + setConfig(newConfig); + setMountEntry(null); + toast.success(`Mounted "${mountEntry.name}" on ${deviceType} #${key}`); }; - const availableDevices = (() => { - const out: { number: string }[] = []; - if (config.devices?.iec) { - for (const [num, d] of Object.entries(config.devices.iec)) { - if ((d as any).type === 'drive' && (d as any).enabled) out.push({ number: num }); - } - } - return out; - })(); - const loadingLabel = dbPhase === 'downloading' ? (dbBytes === null ? 'Loading database…' : `Loading database… ${humanFileSize(dbBytes)}`) : 'Searching…'; const busy = isSearching || isScanning; + // Collect unique facet values from the full result set + const facets = useMemo(() => { + const systems = [...new Set(results.map(r => r.system).filter(Boolean) as string[])].sort(); + const videos = [...new Set(results.map(r => r.video).filter(Boolean) as string[])].sort(); + const langs = [...new Set(results.map(r => r.language).filter(Boolean) as string[])].sort(); + return { systems, videos, langs }; + }, [results]); + + const hasAnyFacets = facets.systems.length > 0 || facets.videos.length > 0 || facets.langs.length > 0; + + const visibleResults = useMemo(() => { + let list = results.filter(r => + (filterSystem === null || r.system === filterSystem) && + (filterVideo === null || r.video === filterVideo) && + (filterLanguage === null || r.language === filterLanguage) + ); + list = [...list].sort((a, b) => { + const cmp = sortField === 'name' ? a.name.localeCompare(b.name) : a.size - b.size; + return sortDir === 'asc' ? cmp : -cmp; + }); + return list; + }, [results, filterSystem, filterVideo, filterLanguage, sortField, sortDir]); + + const toggleSort = (field: SortField) => { + if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); + else { setSortField(field); setSortDir('asc'); } + }; + + const SortIcon = ({ field }: { field: SortField }) => { + if (sortField !== field) return ; + return sortDir === 'asc' ? : ; + }; + + const activeFilters = [filterSystem, filterVideo, filterLanguage].filter(Boolean).length; + return ( + {hasAnyFacets && ( + + )} @@ -214,7 +354,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver value={query} onChange={e => setQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && !busy && handleSearch()} - placeholder="Search games, programs, files…" + placeholder="Search… (* any chars, ? one char)" className="w-full pl-9 pr-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-colors" autoFocus disabled={busy} @@ -229,6 +369,30 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver + {/* Filter panel */} +
+
+ + + +
+ Sort + + +
+
+
+ {/* DB status chip */} {dbPhase === 'ready' && !busy && (

@@ -249,8 +413,8 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver )} - {/* Searching / loading DB progress */} - {isSearching && ( + {/* Spinner — only when no prior results to show */} + {isSearching && !hasSearched && (

{loadingLabel}

@@ -265,15 +429,16 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver )} {/* Results */} - {!busy && !searchError && hasSearched && ( + {!searchError && hasSearched && (
- {results.length > 0 ? ( + {visibleResults.length > 0 ? ( <> -

- {results.length} result{results.length !== 1 ? 's' : ''} +

+ {isSearching && } + {visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}

- {results.map((result, index) => ( + {visibleResults.map((result) => (
-
+
{result.name} + {result.system && {result.system}} + {result.video && {result.video}} + {result.language && {result.language}}

{result.path}

{!result.isDir && ( {result.sizeText} )} -
- - {showDeviceMenu === index && ( -
- {availableDevices.map(d => ( - - ))} - {availableDevices.length === 0 && ( -

No enabled devices

- )} -
- )} -
+
))}
@@ -325,14 +475,24 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver ) : (
-

No results for "{query}"

+

+ {results.length > 0 ? 'No results match the current filters' : `No results for "${query}"`} +

+ {results.length > 0 && ( + + )}
)}
)} {/* Empty state */} - {!busy && !hasSearched && ( + {!isSearching && !hasSearched && (

Search your device

@@ -346,6 +506,61 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
+ + {/* Mount dialog — same pattern as MediaManager */} + !open && setMountEntry(null)}> + + + Mount on Virtual Drive + + {mountEntry?.name} + + +
+ {(() => { + const allDevices = Object.entries(config?.devices?.iec ?? {}); + const drives = allDevices + .filter(([, v]: [string, any]) => (v as any)?.type === 'drive') + .map(([k, v]: [string, any]) => ({ type: 'drive' as const, key: k, base_url: v?.base_url as string | undefined, url: v?.url as string | undefined, enabled: !!v?.enabled })); + const meatloafs = allDevices + .filter(([, v]: [string, any]) => (v as any)?.type === 'meatloaf') + .map(([k, v]: [string, any]) => ({ type: 'meatloaf' as const, key: k, base_url: v?.base_url as string | undefined, url: v?.url as string | undefined, enabled: !!v?.enabled })); + const devices = [...drives, ...meatloafs]; + if (!devices.length) + return

No drive devices found in config.

; + return ( +
+ {devices.map(dev => ( + + ))} +
+ ); + })()} +
+
+
); } diff --git a/src/app/locate-db.ts b/src/app/locate-db.ts index 28ee1fa..32743de 100644 --- a/src/app/locate-db.ts +++ b/src/app/locate-db.ts @@ -166,15 +166,30 @@ export interface LocateEntry { } /** - * Run a case-insensitive substring search against the loaded locate database. + * Convert a user wildcard query (* and ?) to a SQLite LIKE pattern. + * Without wildcards, wraps in % for substring match. + * SQL LIKE specials (% _ \) in the literal parts are escaped. + */ +function toSqlLike(query: string): string { + const hasWildcard = /[*?]/.test(query); + // Escape SQL LIKE special chars that the user did NOT type as wildcards + const escaped = query.replace(/[\\%_]/g, '\\$&'); + if (!hasWildcard) return `%${escaped}%`; + return escaped.replace(/\*/g, '%').replace(/\?/g, '_'); +} + +/** + * Run a case-insensitive search against the loaded locate database. + * Supports * (any chars) and ? (single char) wildcards. + * Without wildcards, performs a substring match. * 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 needle = toSqlLike(query); const rows: LocateEntry[] = []; _db.exec({ - sql: 'SELECT path, size, mtime, is_dir FROM files WHERE path LIKE ? LIMIT ?', + sql: "SELECT path, size, mtime, is_dir FROM files WHERE path LIKE ? ESCAPE '\\' LIMIT ?", bind: [needle, limit], rowMode: 'array', callback: (row: any[]) => {