Compare commits
6 Commits
7c25680091
...
9a0268a6b4
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a0268a6b4 | |||
| 6d12cebc05 | |||
| e93bca4e3e | |||
| 451c9f665b | |||
| 86de697569 | |||
| 30e3cea442 |
|
|
@ -318,6 +318,12 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
|
||||||
config={config}
|
config={config}
|
||||||
setConfig={setConfig}
|
setConfig={setConfig}
|
||||||
onClose={() => setShowSearch(false)}
|
onClose={() => setShowSearch(false)}
|
||||||
|
onOpenFolder={(path) => {
|
||||||
|
setFileManagerInitialPath(path);
|
||||||
|
setFileManagerReturnPage('status');
|
||||||
|
setShowSearch(false);
|
||||||
|
setCurrentPage('file-manager');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -128,76 +128,7 @@ function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: strin
|
||||||
|
|
||||||
// EntryIcon is imported from MediaEntry.
|
// EntryIcon is imported from MediaEntry.
|
||||||
|
|
||||||
// ─── MarqueeText ──────────────────────────────────────────────────────────────
|
import { MarqueeText } from './ui/marquee-text';
|
||||||
// Scrolls text left→right when it overflows its container, then back — ping-pong.
|
|
||||||
// Injects a unique per-instance @keyframes rule with the exact pixel offset so
|
|
||||||
// no CSS variables are needed (more reliable inside Radix portals).
|
|
||||||
|
|
||||||
let _mmSeq = 0;
|
|
||||||
|
|
||||||
function MarqueeText({ children, className }: { children?: string; className?: string }) {
|
|
||||||
const wrapRef = useRef<HTMLSpanElement>(null);
|
|
||||||
const idRef = useRef(`mm${++_mmSeq}`);
|
|
||||||
const [animCSS, setAnimCSS] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const id = idRef.current;
|
|
||||||
const wrap = wrapRef.current;
|
|
||||||
if (!wrap) return;
|
|
||||||
|
|
||||||
const measure = () => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const ov = wrap.scrollWidth - wrap.clientWidth;
|
|
||||||
if (ov <= 0) { setAnimCSS(''); return; }
|
|
||||||
|
|
||||||
// Inject / update the keyframe with the exact measured pixel offset.
|
|
||||||
let el = document.getElementById(`kf-${id}`) as HTMLStyleElement | null;
|
|
||||||
if (!el) {
|
|
||||||
el = document.createElement('style');
|
|
||||||
el.id = `kf-${id}`;
|
|
||||||
document.head.appendChild(el);
|
|
||||||
}
|
|
||||||
el.textContent = `@keyframes ${id}{0%,12%{transform:translateX(0)}88%,100%{transform:translateX(-${ov}px)}}`;
|
|
||||||
|
|
||||||
const secs = (Math.max(2000, ov * 20) / 1000).toFixed(2);
|
|
||||||
setAnimCSS(`${id} ${secs}s ease-in-out 0.8s infinite alternate`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Two nested RAFs ensure the portal element has a stable layout before we
|
|
||||||
// measure (one RAF fires before browser layout; two guarantees post-layout).
|
|
||||||
const r1 = requestAnimationFrame(() => {
|
|
||||||
const r2 = requestAnimationFrame(measure);
|
|
||||||
return () => cancelAnimationFrame(r2);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ro = new ResizeObserver(measure);
|
|
||||||
ro.observe(wrap);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
cancelAnimationFrame(r1);
|
|
||||||
ro.disconnect();
|
|
||||||
document.getElementById(`kf-${id}`)?.remove();
|
|
||||||
setAnimCSS('');
|
|
||||||
};
|
|
||||||
}, [children]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
// overflow:hidden clips the scrolling text; minWidth:0 lets the element
|
|
||||||
// shrink inside grid/flex parents without forcing them to expand to the
|
|
||||||
// full text width (white-space:nowrap lives only on the inner span).
|
|
||||||
<span
|
|
||||||
ref={wrapRef}
|
|
||||||
style={{ overflow: 'hidden', display: 'block', minWidth: 0 }}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<span style={{ display: 'inline-block', whiteSpace: 'nowrap', animation: animCSS || undefined }}>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ActionsModal ─────────────────────────────────────────────────────────────
|
// ─── ActionsModal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,28 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import { X, Search, HardDrive, Loader2, RefreshCw } from 'lucide-react';
|
import { X, Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, ArrowUp, ArrowDown, ArrowUpDown, HardDrive, FolderOpen } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { humanFileSize } from '../webdav';
|
import { humanFileSize, splitPath } from '../webdav';
|
||||||
|
import type { EntryInfo } from '../webdav';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||||
|
import { MediaEntry } from './MediaEntry';
|
||||||
|
import { MarqueeText } from './ui/marquee-text';
|
||||||
import {
|
import {
|
||||||
openLocateDb,
|
openLocateDb,
|
||||||
searchLocate,
|
searchLocate,
|
||||||
isLocateDbLoaded,
|
isLocateDbLoaded,
|
||||||
resetLocateDb,
|
resetLocateDb,
|
||||||
|
buildLocateDb,
|
||||||
type LocateEntry,
|
type LocateEntry,
|
||||||
|
type ScanPhase,
|
||||||
} from '../locate-db';
|
} from '../locate-db';
|
||||||
|
|
||||||
interface SearchOverlayProps {
|
interface SearchOverlayProps {
|
||||||
config: any;
|
config: any;
|
||||||
setConfig: (config: any) => void;
|
setConfig: (config: any) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onOpenFolder: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SearchResult {
|
interface SearchResult {
|
||||||
|
|
@ -24,51 +31,207 @@ interface SearchResult {
|
||||||
type: string;
|
type: string;
|
||||||
size: number;
|
size: number;
|
||||||
sizeText: string;
|
sizeText: string;
|
||||||
|
isDir: boolean;
|
||||||
|
system: string | null;
|
||||||
|
video: string | null;
|
||||||
|
language: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HARDWARE_FILE_EXTS = new Set([
|
const DISK_EXTS = new Set(['d64','d71','d81','d82','dnp','t64','tap','g64','nib']);
|
||||||
'd64', 'd71', 'd81', 'd82', 'dnp', 't64', 'tap', 'prg', 'p00', 'crt', 'bin', '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 {
|
function fileExtension(p: string): string {
|
||||||
const dot = p.lastIndexOf('.');
|
const dot = p.lastIndexOf('.');
|
||||||
return dot < 0 ? '' : p.slice(dot + 1).toLowerCase();
|
return dot < 0 ? '' : p.slice(dot + 1).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function entryToResult(e: LocateEntry): SearchResult {
|
function parseTags(path: string): { system: string | null; video: string | null; language: string | null } {
|
||||||
if (e.is_dir) return { name: e.name, path: e.path, type: 'DIR', size: e.size, sizeText: '—' };
|
const videoM = path.match(VIDEO_RE);
|
||||||
const ext = fileExtension(e.name);
|
const video = videoM ? (VIDEO_NORM[videoM[1].toUpperCase().replace(/[-_]/g, '-')] ?? videoM[1].toUpperCase()) : null;
|
||||||
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) };
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchOverlay({ config, setConfig, onClose }: SearchOverlayProps) {
|
function entryToResult(e: LocateEntry): SearchResult {
|
||||||
const [query, setQuery] = useState('');
|
const tags = parseTags(e.path);
|
||||||
const [systemType, setSystemType] = useState('all');
|
if (e.is_dir) {
|
||||||
const [videoStandard, setVideoStandard] = useState('all');
|
return { name: e.name, path: e.path, type: 'DIR', size: 0, sizeText: '—', isDir: true, ...tags };
|
||||||
const [language, setLanguage] = useState('all');
|
}
|
||||||
|
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,
|
||||||
|
...tags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultToEntry(r: SearchResult): EntryInfo {
|
||||||
|
return { name: r.name, path: r.path, type: r.isDir ? 'folder' : 'file', size: r.size, lastModified: null, contentType: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypeBadge({ type, isDir }: { type: string; isDir: boolean }) {
|
||||||
|
if (isDir) return <span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-mono">DIR</span>;
|
||||||
|
const ext = type.toLowerCase();
|
||||||
|
if (DISK_EXTS.has(ext)) return <span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 font-mono">{type}</span>;
|
||||||
|
if (CART_EXTS.has(ext)) return <span className="text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 font-mono">{type}</span>;
|
||||||
|
if (PRG_EXTS.has(ext)) return <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 font-mono">{type}</span>;
|
||||||
|
return <span className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-600 font-mono">{type}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanPhaseLabel(phase: ScanPhase, value: number): string {
|
||||||
|
if (phase === 'scanning') return `Scanning… ${humanFileSize(value)}`;
|
||||||
|
if (phase === 'building') return `Building… ${value.toLocaleString()} entries`;
|
||||||
|
return `Saving… ${humanFileSize(value)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortField = 'name' | 'size';
|
||||||
|
type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
|
// Persisted across overlay open/close cycles (survives unmount).
|
||||||
|
const _store = {
|
||||||
|
query: '',
|
||||||
|
results: [] as SearchResult[],
|
||||||
|
hasSearched: false,
|
||||||
|
showFilter: false,
|
||||||
|
filterSystem: null as string | null,
|
||||||
|
filterVideo: null as string | null,
|
||||||
|
filterLanguage: null as string | null,
|
||||||
|
sortField: 'name' as SortField,
|
||||||
|
sortDir: 'asc' as SortDir,
|
||||||
|
scrollTop: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
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, onOpenFolder }: SearchOverlayProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [query, setQuery] = useState(() => _store.query);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [results, setResults] = useState<SearchResult[]>([]);
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
const [hasSearched, setHasSearched] = useState(false);
|
const [scanPhase, setScanPhase] = useState<ScanPhase | null>(null);
|
||||||
const [showDeviceMenu, setShowDeviceMenu] = useState<number | null>(null);
|
const [scanValue, setScanValue] = useState(0);
|
||||||
|
const [results, setResults] = useState<SearchResult[]>(() => _store.results);
|
||||||
|
const [hasSearched, setHasSearched] = useState(() => _store.hasSearched);
|
||||||
|
const [mountEntry, setMountEntry] = useState<SearchResult | null>(null);
|
||||||
|
const [actionEntry, setActionEntry] = useState<SearchResult | null>(null);
|
||||||
const [searchError, setSearchError] = useState<string | null>(null);
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
const [dbBytes, setDbBytes] = useState<number | null>(null);
|
const [dbBytes, setDbBytes] = useState<number | null>(null);
|
||||||
const [dbPhase, setDbPhase] = useState<'idle' | 'downloading' | 'ready'>('idle');
|
const [dbPhase, setDbPhase] = useState<'idle' | 'downloading' | 'ready'>('idle');
|
||||||
|
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
||||||
|
const [filterSystem, setFilterSystem] = useState<string | null>(() => _store.filterSystem);
|
||||||
|
const [filterVideo, setFilterVideo] = useState<string | null>(() => _store.filterVideo);
|
||||||
|
const [filterLanguage, setFilterLanguage] = useState<string | null>(() => _store.filterLanguage);
|
||||||
|
const [sortField, setSortField] = useState<SortField>(() => _store.sortField);
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>(() => _store.sortDir);
|
||||||
|
|
||||||
void systemType; void videoStandard; void language;
|
|
||||||
|
|
||||||
// Show "ready" phase if DB was already loaded from a previous search.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLocateDbLoaded()) setDbPhase('ready');
|
if (isLocateDbLoaded()) setDbPhase('ready');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Persist search state so it survives overlay close/reopen.
|
||||||
|
useEffect(() => {
|
||||||
|
_store.query = query;
|
||||||
|
_store.results = results;
|
||||||
|
_store.hasSearched = hasSearched;
|
||||||
|
_store.showFilter = showFilter;
|
||||||
|
_store.filterSystem = filterSystem;
|
||||||
|
_store.filterVideo = filterVideo;
|
||||||
|
_store.filterLanguage = filterLanguage;
|
||||||
|
_store.sortField = sortField;
|
||||||
|
_store.sortDir = sortDir;
|
||||||
|
}, [query, results, hasSearched, showFilter, filterSystem, filterVideo, filterLanguage, sortField, sortDir]);
|
||||||
|
|
||||||
|
// Restore scroll position after results render.
|
||||||
|
useEffect(() => {
|
||||||
|
if (_store.scrollTop > 0 && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = _store.scrollTop;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Build a set of fully-resolved paths currently mounted on any IEC device.
|
||||||
|
const mountedPaths = useMemo(() => {
|
||||||
|
const paths = new Set<string>();
|
||||||
|
for (const d of Object.values(config?.devices?.iec ?? {})) {
|
||||||
|
const dev = d as any;
|
||||||
|
if (!dev?.url) continue;
|
||||||
|
const base = (dev.base_url ?? '').replace(/\/$/, '');
|
||||||
|
const url = dev.url.startsWith('/') ? dev.url : '/' + dev.url;
|
||||||
|
paths.add(base ? base + url : dev.url);
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
const handleSearch = async () => {
|
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);
|
setIsSearching(true);
|
||||||
setHasSearched(true);
|
setHasSearched(true);
|
||||||
setResults([]);
|
|
||||||
setSearchError(null);
|
setSearchError(null);
|
||||||
|
setFilterSystem(null);
|
||||||
|
setFilterVideo(null);
|
||||||
|
setFilterLanguage(null);
|
||||||
try {
|
try {
|
||||||
if (!isLocateDbLoaded()) {
|
if (!isLocateDbLoaded()) {
|
||||||
setDbPhase('downloading');
|
setDbPhase('downloading');
|
||||||
|
|
@ -76,19 +239,18 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
||||||
await openLocateDb(bytes => flushSync(() => setDbBytes(bytes)));
|
await openLocateDb(bytes => flushSync(() => setDbBytes(bytes)));
|
||||||
setDbPhase('ready');
|
setDbPhase('ready');
|
||||||
}
|
}
|
||||||
|
|
||||||
const needle = query.trim();
|
const needle = query.trim();
|
||||||
const entries = searchLocate(needle);
|
const entries = searchLocate(needle);
|
||||||
|
const hasWildcard = /[*?]/.test(needle);
|
||||||
// Sort: name starts with query first, then by path depth (shorter = closer to root).
|
|
||||||
const lower = needle.toLowerCase();
|
|
||||||
entries.sort((a, b) => {
|
entries.sort((a, b) => {
|
||||||
|
if (!hasWildcard) {
|
||||||
|
const lower = needle.toLowerCase();
|
||||||
const aStart = a.name.toLowerCase().startsWith(lower) ? 0 : 1;
|
const aStart = a.name.toLowerCase().startsWith(lower) ? 0 : 1;
|
||||||
const bStart = b.name.toLowerCase().startsWith(lower) ? 0 : 1;
|
const bStart = b.name.toLowerCase().startsWith(lower) ? 0 : 1;
|
||||||
if (aStart !== bStart) return aStart - bStart;
|
if (aStart !== bStart) return aStart - bStart;
|
||||||
|
}
|
||||||
return a.path.length - b.path.length;
|
return a.path.length - b.path.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
setResults(entries.map(entryToResult));
|
setResults(entries.map(entryToResult));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setSearchError(e?.message ?? 'Search failed');
|
setSearchError(e?.message ?? 'Search failed');
|
||||||
|
|
@ -99,7 +261,25 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefreshDb = async () => {
|
const handleScan = async () => {
|
||||||
|
setIsScanning(true);
|
||||||
|
setScanPhase('scanning');
|
||||||
|
setScanValue(0);
|
||||||
|
try {
|
||||||
|
const { count, bytes } = await buildLocateDb((phase, value) =>
|
||||||
|
flushSync(() => { setScanPhase(phase); setScanValue(value); })
|
||||||
|
);
|
||||||
|
toast.success(`Indexed ${count.toLocaleString()} items · ${humanFileSize(bytes)}`);
|
||||||
|
setDbPhase('ready');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(`Scan failed: ${e?.message ?? e}`);
|
||||||
|
} finally {
|
||||||
|
setIsScanning(false);
|
||||||
|
setScanPhase(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshDb = () => {
|
||||||
resetLocateDb();
|
resetLocateDb();
|
||||||
setDbPhase('idle');
|
setDbPhase('idle');
|
||||||
setHasSearched(false);
|
setHasSearched(false);
|
||||||
|
|
@ -107,208 +287,355 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
||||||
toast.info('Database will be reloaded on next search');
|
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));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
if (newConfig.devices?.iec?.[deviceNum]) {
|
if (!newConfig.devices) newConfig.devices = {};
|
||||||
newConfig.devices.iec[deviceNum].url = result.path;
|
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);
|
setConfig(newConfig);
|
||||||
toast.success(`Mounted ${result.name} on Device #${deviceNum}`);
|
setMountEntry(null);
|
||||||
setShowDeviceMenu(null);
|
toast.success(`Mounted "${mountEntry.name}" on ${deviceType} #${key}`);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAvailableDevices = () => {
|
|
||||||
const devices: { number: string; name: string; url?: string }[] = [];
|
|
||||||
if (config.devices?.iec) {
|
|
||||||
for (const [num, device] of Object.entries(config.devices.iec)) {
|
|
||||||
const d = device as any;
|
|
||||||
if (d.type === 'drive' && d.enabled) {
|
|
||||||
devices.push({ number: num, name: `Drive ${num}`, url: d.url });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return devices;
|
|
||||||
};
|
|
||||||
|
|
||||||
const availableDevices = getAvailableDevices();
|
|
||||||
|
|
||||||
const loadingLabel = dbPhase === 'downloading'
|
const loadingLabel = dbPhase === 'downloading'
|
||||||
? (dbBytes === null ? 'Loading database…' : `Loading database… ${humanFileSize(dbBytes)}`)
|
? (dbBytes === null ? 'Loading database…' : `Loading database… ${humanFileSize(dbBytes)}`)
|
||||||
: 'Searching…';
|
: '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 (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 z-50 backdrop-blur-md bg-black/40 flex items-center justify-center"
|
className="fixed inset-0 z-50"
|
||||||
onClick={onClose}
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ y: -50, opacity: 0 }}
|
initial={{ y: '100%' }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0 }}
|
||||||
exit={{ y: -50, opacity: 0 }}
|
exit={{ y: '100%' }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
||||||
className="w-full h-full bg-white/50 shadow-2xl overflow-auto flex flex-col"
|
className="fixed inset-0 bg-white/80 backdrop-blur-md flex flex-col overflow-hidden"
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<div className="p-4 sm:p-6 flex-1 flex flex-col">
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex-shrink-0 px-4 pt-4 pb-3 border-b border-neutral-100">
|
||||||
<h2 className="text-xl font-medium">Search</h2>
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<h2 className="text-base font-semibold text-neutral-800">Search</h2>
|
||||||
{dbPhase === 'ready' && (
|
<div className="flex items-center gap-1">
|
||||||
|
{dbPhase === 'ready' && !busy && (
|
||||||
<button
|
<button
|
||||||
onClick={handleRefreshDb}
|
onClick={handleRefreshDb}
|
||||||
className="p-2 -m-2 hover:bg-neutral-100 rounded-lg text-neutral-400"
|
className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||||
title="Reload database"
|
title="Reload database"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={onClose} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg">
|
<button
|
||||||
<X className="w-6 h-6" />
|
onClick={handleScan}
|
||||||
|
disabled={busy}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 disabled:opacity-40 transition-colors"
|
||||||
|
title="Scan /sd and rebuild database"
|
||||||
|
>
|
||||||
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
{/* Search input */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={e => setQuery(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
onKeyDown={e => e.key === 'Enter' && !busy && handleSearch()}
|
||||||
placeholder="Search for games, programs, files..."
|
placeholder="Search… (* any chars, ? one char)"
|
||||||
className="flex-1 px-4 py-3 border border-neutral-300 rounded-lg text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
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
|
autoFocus
|
||||||
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
disabled={isSearching}
|
disabled={busy || !query.trim()}
|
||||||
className="p-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center"
|
className="px-4 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-medium hover:bg-blue-700 disabled:opacity-40 transition-colors"
|
||||||
aria-label="Search"
|
|
||||||
>
|
>
|
||||||
<Search className="w-5 h-5" />
|
Search
|
||||||
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-neutral-500 block mb-1">System Type</label>
|
|
||||||
<select value={systemType} onChange={(e) => setSystemType(e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg text-sm">
|
|
||||||
<option value="all">All Systems</option>
|
|
||||||
<option value="c64">C64</option>
|
|
||||||
<option value="c128">C128</option>
|
|
||||||
<option value="vic20">VIC-20</option>
|
|
||||||
<option value="plus4">Plus/4</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-neutral-500 block mb-1">Video Standard</label>
|
|
||||||
<select value={videoStandard} onChange={(e) => setVideoStandard(e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg text-sm">
|
|
||||||
<option value="all">All Standards</option>
|
|
||||||
<option value="ntsc">NTSC</option>
|
|
||||||
<option value="pal">PAL</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-neutral-500 block mb-1">Language</label>
|
|
||||||
<select value={language} onChange={(e) => setLanguage(e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg text-sm">
|
|
||||||
<option value="all">All Languages</option>
|
|
||||||
<option value="en">English</option>
|
|
||||||
<option value="es">Spanish</option>
|
|
||||||
<option value="de">German</option>
|
|
||||||
<option value="fr">French</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSearching && (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
|
||||||
<Loader2 className="w-12 h-12 text-blue-600 animate-spin mb-4" />
|
|
||||||
<div className="text-neutral-600">{loadingLabel}</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{!isSearching && searchError && (
|
{/* Body */}
|
||||||
<div className="text-center py-12 text-red-600">Search failed: {searchError}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isSearching && !searchError && hasSearched && (
|
|
||||||
<div className="max-h-96 overflow-y-auto">
|
|
||||||
{results.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="text-sm text-neutral-500 mb-3">
|
|
||||||
{results.length} result{results.length !== 1 ? 's' : ''} found
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{results.map((result, index) => (
|
|
||||||
<div
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex-1 overflow-y-auto"
|
||||||
|
onScroll={e => { _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }}
|
||||||
|
>
|
||||||
|
{/* Scanning progress */}
|
||||||
|
{isScanning && scanPhase && (
|
||||||
|
<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 font-medium text-neutral-700">{scanPhaseLabel(scanPhase, scanValue)}</p>
|
||||||
|
<p className="text-xs text-neutral-400">Scanning /sd recursively…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{!busy && searchError && (
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
<p className="text-sm text-red-600">{searchError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{!searchError && hasSearched && (
|
||||||
|
<div>
|
||||||
|
{visibleResults.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-neutral-400 px-4 py-2 flex items-center gap-1.5 border-b border-neutral-100">
|
||||||
|
{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>
|
||||||
|
{visibleResults.map((result) => (
|
||||||
|
<MediaEntry
|
||||||
key={result.path}
|
key={result.path}
|
||||||
className="bg-neutral-50 border border-neutral-200 rounded-lg p-4 flex items-center justify-between hover:bg-neutral-100"
|
entry={resultToEntry(result)}
|
||||||
>
|
onPrimaryClick={() => setMountEntry(result)}
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
onActionsClick={e => { e.stopPropagation(); setActionEntry(result); }}
|
||||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
selected={mountedPaths.has(result.path)}
|
||||||
<HardDrive className="w-5 h-5 text-blue-600" />
|
nameSlot={
|
||||||
</div>
|
<>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<div className="font-medium truncate">{result.name}</div>
|
<span className="text-neutral-900 text-sm">{result.name}</span>
|
||||||
<div className="text-sm text-neutral-500 truncate">{result.path}</div>
|
<TypeBadge type={result.type} isDir={result.isDir} />
|
||||||
</div>
|
{result.system && <span className="text-xs px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 font-mono">{result.system}</span>}
|
||||||
<div className="text-xs text-neutral-500 flex-shrink-0">
|
{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.type} · {result.sizeText}
|
{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>
|
|
||||||
</div>
|
|
||||||
<div className="relative ml-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowDeviceMenu(showDeviceMenu === index ? null : index)}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm whitespace-nowrap"
|
|
||||||
>
|
|
||||||
Mount
|
|
||||||
</button>
|
|
||||||
{showDeviceMenu === index && (
|
|
||||||
<div className="absolute right-0 top-12 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[150px] z-20">
|
|
||||||
{availableDevices.map((device) => (
|
|
||||||
<button
|
|
||||||
key={device.number}
|
|
||||||
onClick={() => handleMount(device.number, result)}
|
|
||||||
className="w-full px-4 py-2 text-left hover:bg-neutral-50 text-sm"
|
|
||||||
>
|
|
||||||
Device #{device.number}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{availableDevices.length === 0 && (
|
|
||||||
<div className="px-4 py-2 text-sm text-neutral-500">No enabled devices</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-neutral-400 truncate">{result.path}</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12 text-neutral-500">
|
<div className="py-16 text-center">
|
||||||
No results found for "{query}"
|
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" />
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!hasSearched && (
|
{/* Empty state */}
|
||||||
<div className="text-center py-12 text-neutral-400">
|
{!isSearching && !hasSearched && (
|
||||||
<Search className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
<div className="py-16 text-center px-6">
|
||||||
<div>Enter a search term to find files on the device</div>
|
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" />
|
||||||
{dbPhase === 'ready' && (
|
<p className="text-sm font-medium text-neutral-600 mb-1">Search your device</p>
|
||||||
<div className="text-xs mt-2 text-green-600">Database loaded</div>
|
<p className="text-xs text-neutral-400">
|
||||||
)}
|
{dbPhase === 'idle'
|
||||||
|
? 'The locate database will be downloaded on first search, or tap the scan icon to rebuild it.'
|
||||||
|
: 'Type a filename or path to search the index.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Actions dialog */}
|
||||||
|
<Dialog open={actionEntry !== null} onOpenChange={open => !open && setActionEntry(null)}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader className="overflow-hidden min-w-0">
|
||||||
|
<DialogTitle className="sr-only">{actionEntry?.name}</DialogTitle>
|
||||||
|
<p className="text-lg font-semibold leading-none pr-6 overflow-hidden min-w-0">
|
||||||
|
<MarqueeText>{actionEntry?.name}</MarqueeText>
|
||||||
|
</p>
|
||||||
|
<DialogDescription>{actionEntry?.isDir ? 'Folder' : actionEntry?.sizeText}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{!actionEntry?.isDir && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setMountEntry(actionEntry); setActionEntry(null); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<HardDrive className="w-4 h-4 text-amber-600" /> <span>Mount on virtual drive</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const folder = actionEntry ? splitPath(actionEntry.path).parent : '/';
|
||||||
|
setActionEntry(null);
|
||||||
|
onClose();
|
||||||
|
onOpenFolder(folder);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-4 h-4 text-blue-600" /> <span>Open containing folder</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 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>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
66
src/app/components/ui/marquee-text.tsx
Normal file
66
src/app/components/ui/marquee-text.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
let _seq = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls text left→right when it overflows its container (ping-pong).
|
||||||
|
* Injects a unique per-instance @keyframes rule with the exact pixel offset
|
||||||
|
* so no CSS variables are needed — reliable inside Radix portals.
|
||||||
|
*/
|
||||||
|
export function MarqueeText({ children, className }: { children?: string; className?: string }) {
|
||||||
|
const wrapRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const idRef = useRef(`mm${++_seq}`);
|
||||||
|
const [animCSS, setAnimCSS] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const id = idRef.current;
|
||||||
|
const wrap = wrapRef.current;
|
||||||
|
if (!wrap) return;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const ov = wrap.scrollWidth - wrap.clientWidth;
|
||||||
|
if (ov <= 0) { setAnimCSS(''); return; }
|
||||||
|
|
||||||
|
let el = document.getElementById(`kf-${id}`) as HTMLStyleElement | null;
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('style');
|
||||||
|
el.id = `kf-${id}`;
|
||||||
|
document.head.appendChild(el);
|
||||||
|
}
|
||||||
|
el.textContent = `@keyframes ${id}{0%,12%{transform:translateX(0)}88%,100%{transform:translateX(-${ov}px)}}`;
|
||||||
|
|
||||||
|
const secs = (Math.max(2000, ov * 20) / 1000).toFixed(2);
|
||||||
|
setAnimCSS(`${id} ${secs}s ease-in-out 0.8s infinite alternate`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const r1 = requestAnimationFrame(() => {
|
||||||
|
const r2 = requestAnimationFrame(measure);
|
||||||
|
return () => cancelAnimationFrame(r2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(wrap);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cancelAnimationFrame(r1);
|
||||||
|
ro.disconnect();
|
||||||
|
document.getElementById(`kf-${id}`)?.remove();
|
||||||
|
setAnimCSS('');
|
||||||
|
};
|
||||||
|
}, [children]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={wrapRef}
|
||||||
|
style={{ overflow: 'hidden', display: 'block', minWidth: 0 }}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'inline-block', whiteSpace: 'nowrap', animation: animCSS || undefined }}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
||||||
import { getWebDAVBaseUrl, basename } from './webdav';
|
import { getWebDAVBaseUrl, basename, listDirectory, putFileContents, deletePath } from './webdav';
|
||||||
|
|
||||||
const LOCATE_PATH = '/sd/.locate';
|
const LOCATE_PATH = '/sd/.locate';
|
||||||
|
|
||||||
|
|
@ -76,6 +76,87 @@ export async function openLocateDb(onProgress?: (bytes: number) => void): Promis
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ScanPhase = 'scanning' | 'building' | 'saving';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively scan /sd, build a fresh SQLite locate database in memory,
|
||||||
|
* and upload it to /sd/.locate via WebDAV PUT.
|
||||||
|
*
|
||||||
|
* onProgress(phase, value):
|
||||||
|
* scanning → bytes of PROPFIND XML received so far
|
||||||
|
* building → number of entries inserted so far
|
||||||
|
* saving → bytes of the serialized DB written to the server
|
||||||
|
*/
|
||||||
|
export async function buildLocateDb(
|
||||||
|
onProgress?: (phase: ScanPhase, value: number) => void,
|
||||||
|
): Promise<{ count: number; bytes: number }> {
|
||||||
|
const sqlite3 = await getSqlite3();
|
||||||
|
|
||||||
|
// ── 1. Recursive PROPFIND on /sd ────────────────────────────────────────
|
||||||
|
const entries = await listDirectory(
|
||||||
|
'/sd', true,
|
||||||
|
bytes => onProgress?.('scanning', bytes),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 2. Build in-memory DB ───────────────────────────────────────────────
|
||||||
|
const db = new sqlite3.oo1.DB(':memory:', 'ct');
|
||||||
|
db.exec(
|
||||||
|
'CREATE TABLE files (' +
|
||||||
|
' path TEXT PRIMARY KEY,' +
|
||||||
|
' size INTEGER NOT NULL DEFAULT 0,' +
|
||||||
|
' mtime INTEGER NOT NULL DEFAULT 0,' +
|
||||||
|
' is_dir INTEGER NOT NULL DEFAULT 0' +
|
||||||
|
');' +
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_path ON files(path);',
|
||||||
|
);
|
||||||
|
|
||||||
|
db.exec('BEGIN');
|
||||||
|
const stmt = db.prepare('INSERT OR REPLACE INTO files VALUES (?,?,?,?)');
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
const e = entries[i];
|
||||||
|
stmt.bind([
|
||||||
|
e.path,
|
||||||
|
e.size,
|
||||||
|
e.lastModified ? Math.floor(e.lastModified.getTime() / 1000) : 0,
|
||||||
|
e.type === 'folder' ? 1 : 0,
|
||||||
|
]).stepReset();
|
||||||
|
if (i % 250 === 0) onProgress?.('building', i);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stmt.finalize();
|
||||||
|
}
|
||||||
|
db.exec('COMMIT');
|
||||||
|
onProgress?.('building', entries.length);
|
||||||
|
|
||||||
|
// ── 3. Serialize to bytes ────────────────────────────────────────────────
|
||||||
|
const pSaved = sqlite3.wasm.pstack.pointer;
|
||||||
|
let dbBytes: Uint8Array;
|
||||||
|
try {
|
||||||
|
const pSize = sqlite3.wasm.pstack.alloc(8);
|
||||||
|
const pData = sqlite3.capi.sqlite3_serialize(db.pointer, 'main', pSize, 0);
|
||||||
|
if (!pData) throw new Error('sqlite3_serialize returned NULL');
|
||||||
|
const rawSize = sqlite3.wasm.peek(pSize, 'i64');
|
||||||
|
const size = Number(typeof rawSize === 'bigint' ? rawSize : rawSize);
|
||||||
|
dbBytes = sqlite3.wasm.heap8u().slice(pData, pData + size);
|
||||||
|
sqlite3.capi.sqlite3_free(pData);
|
||||||
|
} finally {
|
||||||
|
sqlite3.wasm.pstack.restore(pSaved);
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Delete existing + upload ─────────────────────────────────────────
|
||||||
|
onProgress?.('saving', 0);
|
||||||
|
await deletePath(LOCATE_PATH).catch(() => {});
|
||||||
|
await putFileContents(LOCATE_PATH, dbBytes);
|
||||||
|
onProgress?.('saving', dbBytes.length);
|
||||||
|
|
||||||
|
// Invalidate cached DB so the next search reloads the fresh file.
|
||||||
|
resetLocateDb();
|
||||||
|
|
||||||
|
return { count: entries.length, bytes: dbBytes.length };
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocateEntry {
|
export interface LocateEntry {
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -85,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.
|
* openLocateDb() must have been called (and resolved) before calling this.
|
||||||
*/
|
*/
|
||||||
export function searchLocate(query: string, limit = 500): LocateEntry[] {
|
export function searchLocate(query: string, limit = 500): LocateEntry[] {
|
||||||
if (!_db) throw new Error('Locate database is not loaded');
|
if (!_db) throw new Error('Locate database is not loaded');
|
||||||
const needle = `%${query}%`;
|
const needle = toSqlLike(query);
|
||||||
const rows: LocateEntry[] = [];
|
const rows: LocateEntry[] = [];
|
||||||
_db.exec({
|
_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],
|
bind: [needle, limit],
|
||||||
rowMode: 'array',
|
rowMode: 'array',
|
||||||
callback: (row: any[]) => {
|
callback: (row: any[]) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user