meatloaf-config/src/app/components/SearchLocal.tsx

645 lines
29 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, HardDrive, FolderOpen, X } from 'lucide-react';
import { toast } from 'sonner';
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 {
openLocateDb,
searchLocate,
isLocateDbLoaded,
resetLocateDb,
buildLocateDb,
type LocateEntry,
type ScanPhase,
} from '../locate-db';
interface SearchLocalProps {
config: any;
setConfig: (config: any) => void;
onClose: () => void;
onOpenFolder: (path: string) => void;
}
interface SearchResult {
name: string;
path: string;
type: string;
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']);
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 {
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,
...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';
const _store = {
query: '',
results: [] as SearchResult[],
hasSearched: false,
showFilter: false,
filterText: '',
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 SearchLocal({ config, setConfig, onClose, onOpenFolder }: SearchLocalProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState(() => _store.query);
const [isSearching, setIsSearching] = useState(false);
const [isScanning, setIsScanning] = useState(false);
const [scanPhase, setScanPhase] = useState<ScanPhase | 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 [showScanConfirm, setShowScanConfirm] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
// The locate-database load progress is rendered by the SearchPane (above
// this panel). The pane pre-fetches the database on mount, so by the time
// the user clicks Search the database is almost always ready.
const [dbPhase, setDbPhase] = useState<'idle' | 'ready'>('ready');
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
const [filterText, setFilterText] = useState(() => _store.filterText);
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);
useEffect(() => {
if (isLocateDbLoaded()) setDbPhase('ready');
}, []);
useEffect(() => {
_store.query = query;
_store.results = results;
_store.hasSearched = hasSearched;
_store.showFilter = showFilter;
_store.filterText = filterText;
_store.filterSystem = filterSystem;
_store.filterVideo = filterVideo;
_store.filterLanguage = filterLanguage;
_store.sortField = sortField;
_store.sortDir = sortDir;
}, [query, results, hasSearched, showFilter, filterText, filterSystem, filterVideo, filterLanguage, sortField, sortDir]);
useEffect(() => {
if (_store.scrollTop > 0 && scrollRef.current) {
scrollRef.current.scrollTop = _store.scrollTop;
}
}, []);
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 () => {
if (!query.trim()) { toast.error('Please enter a search term'); return; }
setIsSearching(true);
setHasSearched(true);
setSearchError(null);
setFilterText('');
setFilterSystem(null);
setFilterVideo(null);
setFilterLanguage(null);
try {
// The SearchPane pre-fetches the locate database when it opens, so by
// the time the user clicks Search the database is almost always ready.
// This branch only fires if the user types immediately on mount, before
// the pane's pre-fetch has resolved. The pane-level progress bar above
// the panel already shows the transfer; we don't need a second bar.
if (!isLocateDbLoaded()) {
await openLocateDb();
}
const needle = query.trim();
const entries = searchLocate(needle);
const hasWildcard = /[*?]/.test(needle);
entries.sort((a, b) => {
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));
} catch (e: any) {
setSearchError(e?.message ?? 'Search failed');
resetLocateDb();
setDbPhase('idle');
} finally {
setIsSearching(false);
}
};
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();
setDbPhase('idle');
setHasSearched(false);
setResults([]);
toast.info('Database will be reloaded on next search');
};
const mountOnDevice = (deviceType: 'drive' | 'meatloaf', key: string) => {
if (!mountEntry) return;
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 = 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 busy = isSearching || isScanning;
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 visibleResults = useMemo(() => {
const needle = filterText.trim().toLowerCase();
let list = results.filter(r =>
(!needle || r.name.toLowerCase().includes(needle) || r.path.toLowerCase().includes(needle)) &&
(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, filterText, filterSystem, filterVideo, filterLanguage, sortField, sortDir]);
const toggleSort = (field: SortField) => {
if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortField(field); setSortDir('asc'); }
};
const activeFilters = [filterText || null, filterSystem, filterVideo, filterLanguage].filter(Boolean).length;
return (
<>
<div className="flex flex-col h-full overflow-hidden">
{/* Panel header */}
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-4">
<div className="flex items-center gap-2 flex-1 py-3 min-w-0">
<img src={`${import.meta.env.BASE_URL}favicon.ico`} className="w-5 h-5 flex-shrink-0 object-contain" alt="" aria-hidden="true" />
<span className="text-sm font-semibold text-neutral-700">Local</span>
</div>
<div className="flex items-center gap-0.5">
{dbPhase === 'ready' && !busy && (
<button onClick={handleRefreshDb} className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 transition-colors" title="Reload database">
<RefreshCw className="w-4 h-4" />
</button>
)}
<button onClick={() => setShowScanConfirm(true)} 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>
{hasSearched && (
<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 ml-1 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors" aria-label="Close search">
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Search input */}
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-100">
<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
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !busy && handleSearch()}
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}
/>
</div>
<button
onClick={handleSearch}
disabled={busy || !query.trim()}
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"
>
Search
</button>
</div>
</div>
{/* Filter + sort bar — same style as MediaManager */}
<div className={`overflow-hidden flex-shrink-0 transition-all duration-200 ease-in-out ${showFilter ? 'max-h-40 opacity-100' : 'max-h-0 opacity-0'}`}>
<div className="bg-neutral-50 border-b border-neutral-200 px-4 py-2 flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="relative flex-1 min-w-0">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-neutral-400 pointer-events-none" />
<input
type="text"
value={filterText}
onChange={e => setFilterText(e.target.value)}
placeholder="Filter…"
className="w-full pl-7 pr-6 py-1 text-sm border border-neutral-300 rounded bg-white"
/>
{filterText && (
<button onClick={() => setFilterText('')} className="absolute right-2 top-1/2 -translate-y-1/2">
<X className="w-3.5 h-3.5 text-neutral-400" />
</button>
)}
</div>
{(['name', 'size'] as SortField[]).map(f => (
<button
key={f}
onClick={() => toggleSort(f)}
className={`text-xs px-2 py-1 rounded border flex-shrink-0 ${sortField === f ? 'border-blue-400 bg-blue-50 text-blue-700' : 'border-neutral-300 bg-white text-neutral-600'}`}
>
{f === 'name' ? 'Name' : 'Size'}{sortField === f ? (sortDir === 'asc' ? ' ↑' : ' ↓') : ''}
</button>
))}
</div>
<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>
</div>
{/* Body */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto overflow-x-hidden"
onScroll={e => { _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }}
>
{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>
)}
{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>
<p className="text-xs text-neutral-400">The locate database is loading above.</p>
</div>
)}
{!busy && searchError && (
<div className="p-6 text-center">
<p className="text-sm text-red-600">{searchError}</p>
</div>
)}
{!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}
entry={resultToEntry(result)}
onPrimaryClick={() => setMountEntry(result)}
onActionsClick={e => { e.stopPropagation(); setActionEntry(result); }}
selected={mountedPaths.has(result.path)}
nameSlot={
<>
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span className="text-neutral-900 text-sm">{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>
<div className="text-xs text-neutral-400 truncate">{result.path}</div>
</>
}
/>
))}
</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">
{results.length > 0 ? 'No results match the current filters' : `No results for "${query}"`}
</p>
{results.length > 0 && (
<button
onClick={() => { setFilterText(''); setFilterSystem(null); setFilterVideo(null); setFilterLanguage(null); }}
className="mt-2 text-xs text-blue-600 hover:underline"
>
Clear filters
</button>
)}
</div>
)}
</div>
)}
{!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>
<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>
{/* Scan confirm dialog */}
<Dialog open={showScanConfirm} onOpenChange={setShowScanConfirm}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Rebuild Search Index</DialogTitle>
<DialogDescription>
This will scan all files on <code>/sd</code> and rebuild the local search database. It may take a minute on large collections.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-1">
<button
onClick={() => setShowScanConfirm(false)}
className="px-4 py-2 rounded-xl text-sm text-neutral-600 hover:bg-neutral-100 transition-colors"
>
Cancel
</button>
<button
onClick={() => { setShowScanConfirm(false); handleScan(); }}
className="px-4 py-2 rounded-xl text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
Scan
</button>
</div>
</DialogContent>
</Dialog>
{/* 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 */}
<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>
</>
);
}