feat(SearchOverlay): add filtering and sorting options for search results
This commit is contained in:
parent
30e3cea442
commit
86de697569
|
|
@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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 (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-xs text-neutral-400 font-medium w-14 flex-shrink-0">{label}</span>
|
||||
{values.map(v => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => onSelect(selected === v ? null : v)}
|
||||
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${selected === v ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'}`}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<SearchResult[]>([]);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [showDeviceMenu, setShowDeviceMenu] = useState<number | null>(null);
|
||||
const [mountEntry, setMountEntry] = useState<SearchResult | null>(null);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [dbBytes, setDbBytes] = useState<number | null>(null);
|
||||
const [dbPhase, setDbPhase] = useState<'idle' | 'downloading' | 'ready'>('idle');
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const [filterSystem, setFilterSystem] = useState<string | null>(null);
|
||||
const [filterVideo, setFilterVideo] = useState<string | null>(null);
|
||||
const [filterLanguage, setFilterLanguage] = useState<string | null>(null);
|
||||
const [sortField, setSortField] = useState<SortField>('name');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('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 <ArrowUpDown className="w-3 h-3 opacity-50" />;
|
||||
return sortDir === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />;
|
||||
};
|
||||
|
||||
const activeFilters = [filterSystem, filterVideo, filterLanguage].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
|
|
@ -199,6 +325,20 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
>
|
||||
<FolderSearch className="w-4 h-4" />
|
||||
</button>
|
||||
{hasAnyFacets && (
|
||||
<button
|
||||
onClick={() => setShowFilter(v => !v)}
|
||||
className={`relative p-1.5 rounded-lg transition-colors ${showFilter ? 'bg-blue-100 text-blue-600' : 'hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600'}`}
|
||||
title="Filter & sort"
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
{activeFilters > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 rounded-full bg-blue-600 text-white text-[9px] flex items-center justify-center font-bold">
|
||||
{activeFilters}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 ml-1">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
|
@ -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
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter panel */}
|
||||
<div className={`overflow-hidden transition-all duration-200 ease-in-out ${showFilter && hasAnyFacets ? 'max-h-48 opacity-100 mt-3' : 'max-h-0 opacity-0'}`}>
|
||||
<div className="space-y-2 pb-1">
|
||||
<FilterChips label="System" values={facets.systems} selected={filterSystem} onSelect={setFilterSystem} />
|
||||
<FilterChips label="Video" values={facets.videos} selected={filterVideo} onSelect={setFilterVideo} />
|
||||
<FilterChips label="Language" values={facets.langs} selected={filterLanguage} onSelect={setFilterLanguage} />
|
||||
<div className="flex items-center gap-1.5 pt-0.5 border-t border-neutral-100">
|
||||
<span className="text-xs text-neutral-400 font-medium w-14 flex-shrink-0">Sort</span>
|
||||
<button
|
||||
onClick={() => toggleSort('name')}
|
||||
className={`flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${sortField === 'name' ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'}`}
|
||||
>
|
||||
Name <SortIcon field="name" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleSort('size')}
|
||||
className={`flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${sortField === 'size' ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'}`}
|
||||
>
|
||||
Size <SortIcon field="size" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DB status chip */}
|
||||
{dbPhase === 'ready' && !busy && (
|
||||
<p className="text-xs text-green-600 mt-2 flex items-center gap-1">
|
||||
|
|
@ -249,8 +413,8 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Searching / loading DB progress */}
|
||||
{isSearching && (
|
||||
{/* Spinner — only when no prior results to show */}
|
||||
{isSearching && !hasSearched && (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
||||
<p className="text-sm text-neutral-500">{loadingLabel}</p>
|
||||
|
|
@ -265,15 +429,16 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
)}
|
||||
|
||||
{/* Results */}
|
||||
{!busy && !searchError && hasSearched && (
|
||||
{!searchError && hasSearched && (
|
||||
<div className="p-3">
|
||||
{results.length > 0 ? (
|
||||
{visibleResults.length > 0 ? (
|
||||
<>
|
||||
<p className="text-xs text-neutral-400 px-1 mb-2">
|
||||
{results.length} result{results.length !== 1 ? 's' : ''}
|
||||
<p className="text-xs text-neutral-400 px-1 mb-2 flex items-center gap-1.5">
|
||||
{isSearching && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{results.map((result, index) => (
|
||||
{visibleResults.map((result) => (
|
||||
<div
|
||||
key={result.path}
|
||||
className="bg-neutral-50 border border-neutral-200 rounded-xl px-3 py-2.5 flex items-center gap-3 hover:border-blue-200 hover:bg-blue-50 transition-colors"
|
||||
|
|
@ -285,39 +450,24 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
|
||||
<span className="text-sm font-medium text-neutral-800 truncate">{result.name}</span>
|
||||
<TypeBadge type={result.type} isDir={result.isDir} />
|
||||
{result.system && <span className="text-xs px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 font-mono">{result.system}</span>}
|
||||
{result.video && <span className="text-xs px-1.5 py-0.5 rounded bg-teal-100 text-teal-700 font-mono">{result.video}</span>}
|
||||
{result.language && <span className="text-xs px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 font-mono">{result.language}</span>}
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 truncate mt-0.5">{result.path}</p>
|
||||
</div>
|
||||
{!result.isDir && (
|
||||
<span className="text-xs text-neutral-400 flex-shrink-0">{result.sizeText}</span>
|
||||
)}
|
||||
<div className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setShowDeviceMenu(showDeviceMenu === index ? null : index)}
|
||||
className="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-xs font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Mount
|
||||
</button>
|
||||
{showDeviceMenu === index && (
|
||||
<div className="absolute right-0 top-9 bg-white rounded-xl shadow-lg border border-neutral-200 py-1 min-w-[140px] z-20">
|
||||
{availableDevices.map(d => (
|
||||
<button
|
||||
key={d.number}
|
||||
onClick={() => handleMount(d.number, result)}
|
||||
className="w-full px-4 py-2 text-left hover:bg-neutral-50 text-sm"
|
||||
>
|
||||
Device #{d.number}
|
||||
</button>
|
||||
))}
|
||||
{availableDevices.length === 0 && (
|
||||
<p className="px-4 py-2 text-sm text-neutral-400">No enabled devices</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMountEntry(result)}
|
||||
className="flex-shrink-0 px-3 py-1.5 bg-blue-600 text-white rounded-lg text-xs font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Mount
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -325,14 +475,24 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
) : (
|
||||
<div className="py-16 text-center">
|
||||
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" />
|
||||
<p className="text-sm text-neutral-500">No results for "{query}"</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{results.length > 0 ? 'No results match the current filters' : `No results for "${query}"`}
|
||||
</p>
|
||||
{results.length > 0 && (
|
||||
<button
|
||||
onClick={() => { setFilterSystem(null); setFilterVideo(null); setFilterLanguage(null); }}
|
||||
className="mt-2 text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!busy && !hasSearched && (
|
||||
{!isSearching && !hasSearched && (
|
||||
<div className="py-16 text-center px-6">
|
||||
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" />
|
||||
<p className="text-sm font-medium text-neutral-600 mb-1">Search your device</p>
|
||||
|
|
@ -346,6 +506,61 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Mount dialog — same pattern as MediaManager */}
|
||||
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}>
|
||||
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Mount on Virtual Drive</DialogTitle>
|
||||
<DialogDescription className="truncate" title={mountEntry?.name}>
|
||||
{mountEntry?.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-y-auto flex-1 min-h-0">
|
||||
{(() => {
|
||||
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 <p className="text-sm text-neutral-500 text-center py-4">No drive devices found in config.</p>;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{devices.map(dev => (
|
||||
<button
|
||||
key={`${dev.type}-${dev.key}`}
|
||||
onClick={() => mountOnDevice(dev.type, dev.key)}
|
||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 flex items-center gap-3"
|
||||
>
|
||||
<HardDrive className={`w-5 h-5 flex-shrink-0 ${dev.enabled ? 'text-blue-500' : 'text-neutral-400'}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-sm">Device #{dev.key}</div>
|
||||
{(dev.base_url || dev.url) && (() => {
|
||||
const displayUrl = [dev.base_url, dev.url].filter(Boolean).join('');
|
||||
return (
|
||||
<div
|
||||
className="text-xs text-neutral-500 overflow-hidden whitespace-nowrap"
|
||||
style={{ direction: 'rtl', textOverflow: 'ellipsis' }}
|
||||
title={displayUrl}
|
||||
>
|
||||
<span style={{ direction: 'ltr', unicodeBidi: 'embed' }}>{displayUrl}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{!dev.enabled && <span className="text-xs text-neutral-400 flex-shrink-0">disabled</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user