feat: add SearchLocal and SearchPane components for enhanced local search functionality

This commit is contained in:
Jaime Idolpx 2026-06-14 03:09:12 -04:00
parent 9a0268a6b4
commit ed5a222fd6
4 changed files with 852 additions and 224 deletions

View File

@ -7,7 +7,7 @@ import GeneralPage from './components/GeneralPage';
import NetworkPage from './components/NetworkPage'; import NetworkPage from './components/NetworkPage';
import IECPage from './components/IECPage'; import IECPage from './components/IECPage';
import ToolsPage from './components/ToolsPage'; import ToolsPage from './components/ToolsPage';
import SearchOverlay from './components/SearchOverlay'; import SearchPane from './components/SearchPane';
import RealityOverrideAdminPage from './components/RealityOverrideAdminPage'; import RealityOverrideAdminPage from './components/RealityOverrideAdminPage';
import MediaManager from './components/MediaManager'; import MediaManager from './components/MediaManager';
import ProfilePage from './components/ProfilePage'; import ProfilePage from './components/ProfilePage';
@ -55,6 +55,7 @@ export default function App() {
const [currentPage, setCurrentPage] = useState<Page>('status'); const [currentPage, setCurrentPage] = useState<Page>('status');
const { config, setConfig, saveStatus, pendingCount, flushNow, reload } = useSettings(); const { config, setConfig, saveStatus, pendingCount, flushNow, reload } = useSettings();
const [showSearch, setShowSearch] = useState(false); const [showSearch, setShowSearch] = useState(false);
const [searchInitialTab, setSearchInitialTab] = useState<0 | 1>(0);
const [devicesOpenId, setDevicesOpenId] = useState<string | null>(null); const [devicesOpenId, setDevicesOpenId] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [fileManagerInitialPath, setFileManagerInitialPath] = useState<string | undefined>(undefined); const [fileManagerInitialPath, setFileManagerInitialPath] = useState<string | undefined>(undefined);
@ -117,6 +118,7 @@ export default function App() {
<h2 className="text-lg font-semibold mb-4 text-blue-700">Management</h2> <h2 className="text-lg font-semibold mb-4 text-blue-700">Management</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<AppCard icon={<Folder className="w-7 h-7" />} label="Media Manager" onClick={() => { setFileManagerInitialPath(undefined); setCurrentPage('file-manager'); }} /> <AppCard icon={<Folder className="w-7 h-7" />} label="Media Manager" onClick={() => { setFileManagerInitialPath(undefined); setCurrentPage('file-manager'); }} />
<AppCard icon={<Database className="w-7 h-7" />} label="Assembly64" onClick={() => { setSearchInitialTab(1); setShowSearch(true); }} />
<AppCard icon={<Printer className="w-7 h-7" />} label="Print Manager" onClick={() => setCurrentPage('print-manager')} /> <AppCard icon={<Printer className="w-7 h-7" />} label="Print Manager" onClick={() => setCurrentPage('print-manager')} />
<AppCard icon={<Terminal className="w-7 h-7" />} label="Serial Console" onClick={() => setCurrentPage('serial-console')} /> <AppCard icon={<Terminal className="w-7 h-7" />} label="Serial Console" onClick={() => setCurrentPage('serial-console')} />
<AppCard icon={<Link className="w-7 h-7" />} label="Short Codes" onClick={() => setCurrentPage('serial-console')} /> <AppCard icon={<Link className="w-7 h-7" />} label="Short Codes" onClick={() => setCurrentPage('serial-console')} />
@ -258,7 +260,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
{isFullscreen ? <Minimize2 className="w-5 h-5 text-white" /> : <Maximize2 className="w-5 h-5 text-white" />} {isFullscreen ? <Minimize2 className="w-5 h-5 text-white" /> : <Maximize2 className="w-5 h-5 text-white" />}
</button> </button>
<button <button
onClick={() => setShowSearch(true)} onClick={() => { setSearchInitialTab(0); setShowSearch(true); }}
className="p-2 hover:bg-[#5e5e5e] rounded-lg" className="p-2 hover:bg-[#5e5e5e] rounded-lg"
> >
<Search className="w-5 h-5 text-white" /> <Search className="w-5 h-5 text-white" />
@ -313,19 +315,18 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
</nav> </nav>
{showSearch && ( {showSearch && (
<Suspense fallback={null}> <SearchPane
<SearchOverlay
config={config} config={config}
setConfig={setConfig} setConfig={setConfig}
initialTab={searchInitialTab}
onClose={() => setShowSearch(false)} onClose={() => setShowSearch(false)}
onOpenFolder={(path) => { onOpenFolder={(path: string) => {
setFileManagerInitialPath(path); setFileManagerInitialPath(path);
setFileManagerReturnPage('status'); setFileManagerReturnPage('status');
setShowSearch(false); setShowSearch(false);
setCurrentPage('file-manager'); setCurrentPage('file-manager');
}} }}
/> />
</Suspense>
)} )}
</div> </div>
</WsProvider> </WsProvider>

View File

@ -0,0 +1,555 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Search, Loader2, HardDrive, Download, ChevronRight, Trophy, Calendar, Users, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { humanFileSize, basename, joinPath, putFileContents } from '../webdav';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
import { MarqueeText } from './ui/marquee-text';
// ─── API ──────────────────────────────────────────────────────────────────────
const LEET_BASE = 'https://hackerswithstyle.se/leet';
const PAGE_SIZE = 50;
const DOWNLOAD_DIR = '/sd/downloads';
function leetFetch(path: string, query?: Record<string, string>) {
const url = new URL(LEET_BASE + path);
if (query) Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v));
return fetch(url.toString(), { headers: { 'client-id': 'meatloaf-config' } });
}
async function leetJson<T>(path: string, query?: Record<string, string>): Promise<T> {
const res = await leetFetch(path, query);
const data = await res.json();
if (!res.ok || (data as any).errorCode) throw new Error(`Leet API error ${(data as any).errorCode ?? res.status}`);
return data as T;
}
// ─── Types ────────────────────────────────────────────────────────────────────
interface ContentItem {
id: string;
name: string;
category: number;
group?: string;
handle?: string;
year?: number;
country?: string;
event?: string;
rating?: number;
siteRating?: number;
place?: number;
compo?: number;
updated?: string;
released?: string;
}
interface ContentEntry {
id: number;
path: string;
size: number;
date: number;
}
interface CategoryMapping {
id: number;
name?: string;
title?: string;
[k: string]: unknown;
}
interface Preset {
name?: string;
title?: string;
query?: string;
aql?: string;
[k: string]: unknown;
}
// ─── Props ────────────────────────────────────────────────────────────────────
interface SearchAssembly64Props {
config: any;
setConfig: (c: any) => void;
onClose: () => void;
}
// ─── Module-level persistence ─────────────────────────────────────────────────
const _store = {
query: '',
results: [] as ContentItem[],
offset: 0,
hasMore: false,
hasSearched: false,
categoryFilter: null as number | null,
scrollTop: 0,
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
function entryFilename(e: ContentEntry): string {
return basename(e.path) || e.path;
}
function downloadUrl(item: ContentItem, entry: ContentEntry): string {
return `${LEET_BASE}/search/bin/${item.id}/${item.category}/${entry.id}`;
}
function RatingStars({ value, max = 10 }: { value?: number; max?: number }) {
if (!value) return null;
const pct = Math.round((value / max) * 5);
return (
<span className="text-amber-400 text-xs">
{'★'.repeat(pct)}{'☆'.repeat(5 - pct)}
</span>
);
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function SearchAssembly64({ config, setConfig, onClose: _onClose }: SearchAssembly64Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState(() => _store.query);
const [results, setResults] = useState<ContentItem[]>(() => _store.results);
const [offset, setOffset] = useState(() => _store.offset);
const [hasMore, setHasMore] = useState(() => _store.hasMore);
const [hasSearched, setHasSearched] = useState(() => _store.hasSearched);
const [isSearching, setIsSearching] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [categoryFilter, setCategoryFilter] = useState<number | null>(() => _store.categoryFilter);
const [categories, setCategories] = useState<CategoryMapping[]>([]);
const [presets, setPresets] = useState<Preset[]>([]);
const [selectedItem, setSelectedItem] = useState<ContentItem | null>(null);
const [entries, setEntries] = useState<ContentEntry[] | null>(null);
const [loadingEntries, setLoadingEntries] = useState(false);
const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null);
const [downloading, setDownloading] = useState<number | null>(null);
useEffect(() => {
_store.query = query;
_store.results = results;
_store.offset = offset;
_store.hasMore = hasMore;
_store.hasSearched = hasSearched;
_store.categoryFilter = categoryFilter;
}, [query, results, offset, hasMore, hasSearched, categoryFilter]);
useEffect(() => {
if (_store.scrollTop > 0 && scrollRef.current)
scrollRef.current.scrollTop = _store.scrollTop;
}, []);
useEffect(() => {
leetJson<CategoryMapping[]>('/search/categories').then(setCategories).catch(() => {});
leetJson<Preset[]>('/search/aql/presets').then(setPresets).catch(() => {});
}, []);
const categoryName = useMemo(() => {
const map: Record<number, string> = {};
for (const c of categories) map[c.id] = (c.name ?? c.title ?? String(c.id)) as string;
return map;
}, [categories]);
// ── Search ──────────────────────────────────────────────────────────────────
const doSearch = async (q: string, cat: number | null, fromOffset: number, append = false) => {
if (!append) setIsSearching(true);
else setIsLoadingMore(true);
setSearchError(null);
try {
let aql = q.trim();
if (cat !== null) aql = aql ? `${aql} category:${cat}` : `category:${cat}`;
const data = await leetJson<ContentItem[]>(
`/search/aql/${fromOffset}/${PAGE_SIZE}`,
aql ? { query: aql } : undefined,
);
if (append) setResults(prev => [...prev, ...data]);
else setResults(data);
setOffset(fromOffset + data.length);
setHasMore(data.length === PAGE_SIZE);
setHasSearched(true);
} catch (e: any) {
setSearchError(e?.message ?? 'Search failed');
} finally {
setIsSearching(false);
setIsLoadingMore(false);
}
};
const handleSearch = () => doSearch(query, categoryFilter, 0);
const handleLoadMore = () => doSearch(query, categoryFilter, offset, true);
const applyPreset = (p: Preset) => {
const q = (p.query ?? p.aql ?? '') as string;
setQuery(q);
doSearch(q, categoryFilter, 0);
};
// ── Item entries ─────────────────────────────────────────────────────────────
const openItem = async (item: ContentItem) => {
setSelectedItem(item);
setEntries(null);
setLoadingEntries(true);
try {
const data = await leetJson<ContentEntry[]>(`/search/entries/${item.id}/${item.category}`);
setEntries(data);
} catch (e: any) {
toast.error(`Failed to load entries: ${e?.message ?? e}`);
setEntries([]);
} finally {
setLoadingEntries(false);
}
};
// ── Download to SD ───────────────────────────────────────────────────────────
const downloadToSd = async (item: ContentItem, entry: ContentEntry) => {
setDownloading(entry.id);
try {
const url = downloadUrl(item, entry);
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.arrayBuffer();
const dest = joinPath(DOWNLOAD_DIR, entryFilename(entry));
await putFileContents(dest, data);
toast.success(`Saved to ${dest}`);
} catch (e: any) {
toast.error(`Download failed: ${e?.message ?? e}`);
} finally {
setDownloading(null);
}
};
// ── Mount ────────────────────────────────────────────────────────────────────
const mountOnDevice = (deviceType: 'drive' | 'meatloaf', key: string) => {
if (!mountEntry) return;
const { item, entry } = mountEntry;
const newConfig = JSON.parse(JSON.stringify(config));
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 = downloadUrl(item, entry);
delete dev.media_set;
if (!dev.enabled) dev.enabled = 1;
setConfig(newConfig);
setMountEntry(null);
toast.success(`Mounted "${entryFilename(entry)}" on ${deviceType} #${key}`);
};
const mountedUrls = useMemo(() => {
const s = new Set<string>();
for (const d of Object.values(config?.devices?.iec ?? {})) {
const dev = d as any;
if (dev?.url) s.add(dev.url);
}
return s;
}, [config]);
// ── Render ────────────────────────────────────────────────────────────────────
return (
<>
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-200/70">
{/* Search input */}
<div className="flex gap-2 mb-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
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !isSearching && handleSearch()}
placeholder="name:manic* group:ultimate year:1983…"
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"
disabled={isSearching}
/>
</div>
<button
onClick={handleSearch}
disabled={isSearching}
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"
>
{isSearching ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Search'}
</button>
</div>
{/* Presets */}
{presets.length > 0 && (
<div className="flex gap-1.5 mb-2 overflow-x-auto pb-0.5 scrollbar-none">
{presets.map((p, i) => {
const label = (p.name ?? p.title ?? `Preset ${i + 1}`) as string;
return (
<button
key={i}
onClick={() => applyPreset(p)}
className="flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium bg-neutral-100 text-neutral-600 hover:bg-neutral-200 transition-colors"
>
{label}
</button>
);
})}
</div>
)}
{/* Category filter */}
{categories.length > 0 && (
<div className="flex gap-1.5 overflow-x-auto pb-0.5 scrollbar-none">
<button
onClick={() => { setCategoryFilter(null); if (hasSearched) doSearch(query, null, 0); }}
className={`flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${categoryFilter === null ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'}`}
>
All
</button>
{categories.map(c => {
const name = (c.name ?? c.title ?? String(c.id)) as string;
return (
<button
key={c.id}
onClick={() => { setCategoryFilter(c.id); if (hasSearched) doSearch(query, c.id, 0); }}
className={`flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${categoryFilter === c.id ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'}`}
>
{name}
</button>
);
})}
</div>
)}
</div>
{/* Body */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto"
onScroll={e => { _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }}
>
{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">Searching</p>
</div>
)}
{searchError && (
<div className="p-6 text-center">
<p className="text-sm text-red-500">{searchError}</p>
<button onClick={handleSearch} className="mt-2 inline-flex items-center gap-1 text-xs text-blue-600">
<RefreshCw className="w-3 h-3" /> Retry
</button>
</div>
)}
{!searchError && hasSearched && (
<>
{results.length > 0 ? (
<>
<p className="text-xs text-neutral-400 px-4 py-2 border-b border-neutral-100 flex items-center gap-1.5">
{isSearching && <Loader2 className="w-3 h-3 animate-spin" />}
{results.length} result{results.length !== 1 ? 's' : ''}{hasMore ? '+' : ''}
</p>
{results.map(item => {
const catLabel = categoryName[item.category] ?? `Cat ${item.category}`;
return (
<button
key={item.id}
onClick={() => openItem(item)}
className="w-full pl-4 pr-4 py-3 flex items-center gap-3 border-b border-neutral-100 border-l-2 border-l-transparent transition-colors hover:bg-blue-50 hover:border-l-blue-400 text-left"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-neutral-900 truncate">{item.name}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 font-mono flex-shrink-0">{catLabel}</span>
{item.place === 1 && <Trophy className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />}
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{item.group && (
<span className="text-xs text-neutral-400 flex items-center gap-0.5">
<Users className="w-3 h-3" />{item.group}
</span>
)}
{item.handle && !item.group && (
<span className="text-xs text-neutral-400">{item.handle}</span>
)}
{item.year && (
<span className="text-xs text-neutral-400 flex items-center gap-0.5">
<Calendar className="w-3 h-3" />{item.year}
</span>
)}
{item.event && (
<span className="text-xs text-neutral-400 truncate">{item.event}</span>
)}
{item.place && (
<span className="text-xs text-neutral-400">#{item.place}</span>
)}
</div>
</div>
<div className="flex-shrink-0 flex flex-col items-end gap-0.5">
{item.siteRating != null && <RatingStars value={item.siteRating} />}
<ChevronRight className="w-4 h-4 text-neutral-300" />
</div>
</button>
);
})}
{hasMore && (
<div className="p-4 flex justify-center">
<button
onClick={handleLoadMore}
disabled={isLoadingMore}
className="px-6 py-2 bg-neutral-100 rounded-xl text-sm text-neutral-600 hover:bg-neutral-200 disabled:opacity-40 inline-flex items-center gap-2"
>
{isLoadingMore ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
Load more
</button>
</div>
)}
</>
) : (
<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</p>
</div>
)}
</>
)}
{!hasSearched && !isSearching && (
<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 the Assembly64 database</p>
<p className="text-xs text-neutral-400">
Use AQL syntax: <code className="bg-neutral-100 px-1 rounded">name:manic*</code>,{' '}
<code className="bg-neutral-100 px-1 rounded">group:triad</code>,{' '}
<code className="bg-neutral-100 px-1 rounded">year:1983</code>
</p>
</div>
)}
</div>
</div>
{/* Item entries dialog */}
<Dialog open={selectedItem !== null} onOpenChange={open => !open && setSelectedItem(null)}>
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
<DialogHeader className="flex-shrink-0 overflow-hidden min-w-0">
<DialogTitle className="sr-only">{selectedItem?.name}</DialogTitle>
<p className="text-lg font-semibold leading-none pr-6 overflow-hidden min-w-0">
<MarqueeText>{selectedItem?.name}</MarqueeText>
</p>
<DialogDescription>
{[selectedItem?.group, selectedItem?.year, selectedItem?.event].filter(Boolean).join(' · ')}
</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto flex-1 min-h-0">
{loadingEntries && (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
</div>
)}
{!loadingEntries && entries?.length === 0 && (
<p className="text-sm text-neutral-400 text-center py-8">No files found</p>
)}
{!loadingEntries && entries && entries.length > 0 && (
<div className="flex flex-col gap-2 py-1">
{entries.map(entry => {
const fname = entryFilename(entry);
const isMounted = mountedUrls.has(downloadUrl(selectedItem!, entry));
return (
<div
key={entry.id}
className={`px-4 py-3 rounded-lg border flex items-center gap-3 ${isMounted ? 'border-blue-300 bg-blue-50' : 'border-neutral-200'}`}
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-neutral-800 truncate">{fname}</div>
<div className="text-xs text-neutral-400">{humanFileSize(entry.size)}</div>
</div>
<button
onClick={() => downloadToSd(selectedItem!, entry)}
disabled={downloading === entry.id}
className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 disabled:opacity-40"
title="Download to /sd/downloads"
>
{downloading === entry.id
? <Loader2 className="w-4 h-4 animate-spin" />
: <Download className="w-4 h-4" />}
</button>
<button
onClick={() => setMountEntry({ item: selectedItem!, entry })}
className={`p-1.5 rounded-lg ${isMounted ? 'text-blue-600' : 'hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600'}`}
title="Mount on virtual drive"
>
<HardDrive className="w-4 h-4" />
</button>
</div>
);
})}
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Mount device picker */}
<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">
{mountEntry ? entryFilename(mountEntry.entry) : ''}
</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) && (
<div
className="text-xs text-neutral-500 overflow-hidden whitespace-nowrap"
style={{ direction: 'rtl', textOverflow: 'ellipsis' }}
title={[dev.base_url, dev.url].filter(Boolean).join('')}
>
<span style={{ direction: 'ltr', unicodeBidi: 'embed' }}>
{[dev.base_url, dev.url].filter(Boolean).join('')}
</span>
</div>
)}
</div>
{!dev.enabled && <span className="text-xs text-neutral-400 flex-shrink-0">disabled</span>}
</button>
))}
</div>
);
})()}
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import { X, Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, ArrowUp, ArrowDown, ArrowUpDown, HardDrive, FolderOpen } from 'lucide-react'; import { Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, ArrowUp, ArrowDown, ArrowUpDown, HardDrive, FolderOpen } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { humanFileSize, splitPath } from '../webdav'; import { humanFileSize, splitPath } from '../webdav';
import type { EntryInfo } from '../webdav'; import type { EntryInfo } from '../webdav';
@ -18,7 +17,7 @@ import {
type ScanPhase, type ScanPhase,
} from '../locate-db'; } from '../locate-db';
interface SearchOverlayProps { interface SearchLocalProps {
config: any; config: any;
setConfig: (config: any) => void; setConfig: (config: any) => void;
onClose: () => void; onClose: () => void;
@ -41,7 +40,6 @@ const DISK_EXTS = new Set(['d64','d71','d81','d82','dnp','t64','tap','g64','nib'
const CART_EXTS = new Set(['crt','bin']); const CART_EXTS = new Set(['crt','bin']);
const PRG_EXTS = new Set(['prg','p00']); 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_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> = { const LANG_NAMES: Record<string, string> = {
En:'English', De:'German', Fr:'French', Es:'Spanish', It:'Italian', En:'English', De:'German', Fr:'French', Es:'Spanish', It:'Italian',
@ -127,7 +125,6 @@ function scanPhaseLabel(phase: ScanPhase, value: number): string {
type SortField = 'name' | 'size'; type SortField = 'name' | 'size';
type SortDir = 'asc' | 'desc'; type SortDir = 'asc' | 'desc';
// Persisted across overlay open/close cycles (survives unmount).
const _store = { const _store = {
query: '', query: '',
results: [] as SearchResult[], results: [] as SearchResult[],
@ -166,7 +163,7 @@ function FilterChips({
); );
} }
export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder }: SearchOverlayProps) { export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }: SearchLocalProps) {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState(() => _store.query); const [query, setQuery] = useState(() => _store.query);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
@ -191,7 +188,6 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder
if (isLocateDbLoaded()) setDbPhase('ready'); if (isLocateDbLoaded()) setDbPhase('ready');
}, []); }, []);
// Persist search state so it survives overlay close/reopen.
useEffect(() => { useEffect(() => {
_store.query = query; _store.query = query;
_store.results = results; _store.results = results;
@ -204,14 +200,12 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder
_store.sortDir = sortDir; _store.sortDir = sortDir;
}, [query, results, hasSearched, showFilter, filterSystem, filterVideo, filterLanguage, sortField, sortDir]); }, [query, results, hasSearched, showFilter, filterSystem, filterVideo, filterLanguage, sortField, sortDir]);
// Restore scroll position after results render.
useEffect(() => { useEffect(() => {
if (_store.scrollTop > 0 && scrollRef.current) { if (_store.scrollTop > 0 && scrollRef.current) {
scrollRef.current.scrollTop = _store.scrollTop; scrollRef.current.scrollTop = _store.scrollTop;
} }
}, []); }, []);
// Build a set of fully-resolved paths currently mounted on any IEC device.
const mountedPaths = useMemo(() => { const mountedPaths = useMemo(() => {
const paths = new Set<string>(); const paths = new Set<string>();
for (const d of Object.values(config?.devices?.iec ?? {})) { for (const d of Object.values(config?.devices?.iec ?? {})) {
@ -308,7 +302,6 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder
const busy = isSearching || isScanning; const busy = isSearching || isScanning;
// Collect unique facet values from the full result set
const facets = useMemo(() => { const facets = useMemo(() => {
const systems = [...new Set(results.map(r => r.system).filter(Boolean) as string[])].sort(); 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 videos = [...new Set(results.map(r => r.video).filter(Boolean) as string[])].sort();
@ -344,25 +337,11 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder
const activeFilters = [filterSystem, filterVideo, filterLanguage].filter(Boolean).length; const activeFilters = [filterSystem, filterVideo, filterLanguage].filter(Boolean).length;
return ( return (
<AnimatePresence> <>
<motion.div <div className="flex flex-col h-full overflow-hidden">
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50"
>
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
className="fixed inset-0 bg-white/80 backdrop-blur-md flex flex-col overflow-hidden"
>
{/* Header */} {/* Header */}
<div className="flex-shrink-0 px-4 pt-4 pb-3 border-b border-neutral-100"> <div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-100">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-end mb-3 gap-1">
<h2 className="text-base font-semibold text-neutral-800">Search</h2>
<div className="flex items-center gap-1">
{dbPhase === 'ready' && !busy && ( {dbPhase === 'ready' && !busy && (
<button <button
onClick={handleRefreshDb} onClick={handleRefreshDb}
@ -394,10 +373,6 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder
)} )}
</button> </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>
</div>
</div> </div>
{/* Search input */} {/* Search input */}
@ -447,7 +422,6 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* Body */} {/* Body */}
@ -456,7 +430,6 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder
className="flex-1 overflow-y-auto" className="flex-1 overflow-y-auto"
onScroll={e => { _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }} onScroll={e => { _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }}
> >
{/* Scanning progress */}
{isScanning && scanPhase && ( {isScanning && scanPhase && (
<div className="flex flex-col items-center justify-center py-16 gap-3"> <div className="flex flex-col items-center justify-center py-16 gap-3">
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" /> <Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
@ -465,7 +438,6 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder
</div> </div>
)} )}
{/* Spinner — only when no prior results to show */}
{isSearching && !hasSearched && ( {isSearching && !hasSearched && (
<div className="flex flex-col items-center justify-center py-16 gap-3"> <div className="flex flex-col items-center justify-center py-16 gap-3">
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" /> <Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
@ -473,14 +445,12 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder
</div> </div>
)} )}
{/* Error */}
{!busy && searchError && ( {!busy && searchError && (
<div className="p-6 text-center"> <div className="p-6 text-center">
<p className="text-sm text-red-600">{searchError}</p> <p className="text-sm text-red-600">{searchError}</p>
</div> </div>
)} )}
{/* Results */}
{!searchError && hasSearched && ( {!searchError && hasSearched && (
<div> <div>
{visibleResults.length > 0 ? ( {visibleResults.length > 0 ? (
@ -532,7 +502,6 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder
</div> </div>
)} )}
{/* Empty state */}
{!isSearching && !hasSearched && ( {!isSearching && !hasSearched && (
<div className="py-16 text-center px-6"> <div className="py-16 text-center px-6">
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" /> <Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" />
@ -545,8 +514,7 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder
</div> </div>
)} )}
</div> </div>
</motion.div> </div>
</motion.div>
{/* Actions dialog */} {/* Actions dialog */}
<Dialog open={actionEntry !== null} onOpenChange={open => !open && setActionEntry(null)}> <Dialog open={actionEntry !== null} onOpenChange={open => !open && setActionEntry(null)}>
@ -582,7 +550,7 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Mount dialog — same pattern as MediaManager */} {/* Mount dialog */}
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}> <Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}>
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]"> <DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
<DialogHeader className="flex-shrink-0"> <DialogHeader className="flex-shrink-0">
@ -636,6 +604,6 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</AnimatePresence> </>
); );
} }

View File

@ -0,0 +1,104 @@
import { useEffect, useRef, useState } from 'react';
import { X } from 'lucide-react';
import { motion } from 'motion/react';
import SearchLocal from './SearchLocal';
import SearchAssembly64 from './SearchAssembly64';
interface SearchPaneProps {
config: any;
setConfig: (c: any) => void;
initialTab?: 0 | 1;
onClose: () => void;
onOpenFolder: (path: string) => void;
}
const TABS = ['Local', 'Assembly64'] as const;
export default function SearchPane({ config, setConfig, initialTab = 0, onClose, onOpenFolder }: SearchPaneProps) {
const panelRef = useRef<HTMLDivElement>(null);
const [activeTab, setActiveTab] = useState<0 | 1>(initialTab);
// Jump to initialTab without animation on first render
useEffect(() => {
const el = panelRef.current;
if (!el || initialTab === 0) return;
el.scrollLeft = initialTab * el.clientWidth;
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const scrollToTab = (idx: 0 | 1) => {
const el = panelRef.current;
if (!el) return;
el.scrollTo({ left: idx * el.clientWidth, behavior: 'smooth' });
};
const handleScroll = () => {
const el = panelRef.current;
if (!el) return;
const idx = Math.round(el.scrollLeft / el.clientWidth) as 0 | 1;
setActiveTab(idx);
};
return (
<div className="fixed inset-0 z-50">
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
className="fixed inset-0 bg-white/80 backdrop-blur-md flex flex-col overflow-hidden"
>
{/* Tab bar */}
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-1">
{TABS.map((label, i) => (
<button
key={label}
onClick={() => scrollToTab(i as 0 | 1)}
className={`px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === i
? 'text-blue-600 border-blue-600'
: 'text-neutral-500 border-transparent hover:text-neutral-700'
}`}
>
{label}
</button>
))}
<div className="flex-1" />
<button
onClick={onClose}
className="p-1.5 mr-2 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors"
aria-label="Close search"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Swipeable panels */}
<div
ref={panelRef}
className="flex flex-1 min-h-0 overflow-x-auto snap-x snap-mandatory"
style={{ scrollbarWidth: 'none', WebkitOverflowScrolling: 'touch' } as React.CSSProperties}
onScroll={handleScroll}
>
{/* Local panel */}
<div className="w-full h-full flex-shrink-0 snap-start flex flex-col min-w-0">
<SearchLocal
config={config}
setConfig={setConfig}
onClose={onClose}
onOpenFolder={onOpenFolder}
/>
</div>
{/* Assembly64 panel */}
<div className="w-full h-full flex-shrink-0 snap-start flex flex-col min-w-0">
<SearchAssembly64
config={config}
setConfig={setConfig}
onClose={onClose}
/>
</div>
</div>
</motion.div>
</div>
);
}