581 lines
28 KiB
TypeScript
581 lines
28 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Search, Loader2, HardDrive, ChevronRight, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { basename, clearDirectory, humanFileSize, joinPath, putFileContents, streamFetch, type FetchProgress } from '../webdav';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
|
import { MarqueeText } from './ui/marquee-text';
|
|
import { EntryIcon } from './MediaEntry';
|
|
|
|
// ─── API ──────────────────────────────────────────────────────────────────────
|
|
|
|
const CSDB_API = 'https://api.idolpx.com/csdb';
|
|
const CSDB_DOWNLOAD = 'https://csdb.idolpx.com/data';
|
|
const DOWNLOAD_DIR = '/sd/downloads/csdbng';
|
|
|
|
function resolveLink(link: string): string {
|
|
return CSDB_DOWNLOAD + link;
|
|
}
|
|
|
|
function isRelativeLink(link: string): boolean {
|
|
return !link.startsWith('http://') && !link.startsWith('https://');
|
|
}
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
interface CsdbRow {
|
|
id: string;
|
|
name: string;
|
|
tags: string;
|
|
}
|
|
|
|
interface CsdbDownloadLink {
|
|
Link: string;
|
|
CounterLink: string;
|
|
Downloads: number;
|
|
Status: string;
|
|
Filename: string;
|
|
hash: string;
|
|
}
|
|
|
|
interface CsdbRelease {
|
|
ID: number;
|
|
Name: string;
|
|
Type: string;
|
|
ScreenShot: string[];
|
|
DownloadLinks: CsdbDownloadLink[];
|
|
}
|
|
|
|
type SortDir = 'asc' | 'desc';
|
|
|
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
|
|
|
interface SearchCSDbNGProps {
|
|
config: any;
|
|
setConfig: (c: any) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
// ─── Module-level persistence ─────────────────────────────────────────────────
|
|
|
|
const _store = {
|
|
query: '',
|
|
results: [] as CsdbRow[],
|
|
hasSearched: false,
|
|
tagFilter: null as string | null,
|
|
scrollTop: 0,
|
|
showFilter: false,
|
|
filterText: '',
|
|
sortDir: 'asc' as SortDir,
|
|
};
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
// "c64 crack" → "crack", "c64 music collection" → "music collection"
|
|
function typeLabel(tags: string): string {
|
|
return tags.split(' ').slice(1).join(' ') || tags;
|
|
}
|
|
|
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
|
|
export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbNGProps) {
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [query, setQuery] = useState(() => _store.query);
|
|
const [results, setResults] = useState<CsdbRow[]>(() => _store.results);
|
|
const [hasSearched, setHasSearched] = useState(() => _store.hasSearched);
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
const [searchError, setSearchError] = useState<string | null>(null);
|
|
const [tagFilter, setTagFilter] = useState<string | null>(() => _store.tagFilter);
|
|
|
|
const [selectedRow, setSelectedRow] = useState<CsdbRow | null>(null);
|
|
const [release, setRelease] = useState<CsdbRelease | null>(null);
|
|
const [loadingRelease, setLoadingRelease] = useState(false);
|
|
|
|
const [actionRow, setActionRow] = useState<CsdbRow | null>(null);
|
|
const [mountEntry, setMountEntry] = useState<{ row: CsdbRow; link: CsdbDownloadLink } | null>(null);
|
|
const [isMounting, setIsMounting] = useState(false);
|
|
const [mountProgress, setMountProgress] = useState<FetchProgress & { phase: 'fetching' | 'saving' | 'image' } | null>(null);
|
|
|
|
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
|
const [filterText, setFilterText] = useState(() => _store.filterText);
|
|
const [sortDir, setSortDir] = useState<SortDir>(() => _store.sortDir);
|
|
|
|
useEffect(() => {
|
|
_store.query = query;
|
|
_store.results = results;
|
|
_store.hasSearched = hasSearched;
|
|
_store.tagFilter = tagFilter;
|
|
_store.showFilter = showFilter;
|
|
_store.filterText = filterText;
|
|
_store.sortDir = sortDir;
|
|
}, [query, results, hasSearched, tagFilter, showFilter, filterText, sortDir]);
|
|
|
|
useEffect(() => {
|
|
if (_store.scrollTop > 0 && scrollRef.current)
|
|
scrollRef.current.scrollTop = _store.scrollTop;
|
|
}, []);
|
|
|
|
// ── Search ──────────────────────────────────────────────────────────────────
|
|
|
|
const doSearch = async (q: string) => {
|
|
if (!q.trim()) return;
|
|
setIsSearching(true);
|
|
setSearchError(null);
|
|
setTagFilter(null);
|
|
setFilterText('');
|
|
try {
|
|
const res = await fetch(`${CSDB_API}/search/${encodeURIComponent(q.trim())}`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data: { count: number; rows: CsdbRow[] } = await res.json();
|
|
setResults(data.rows ?? []);
|
|
setHasSearched(true);
|
|
} catch (e: any) {
|
|
setSearchError(e?.message ?? 'Search failed');
|
|
} finally {
|
|
setIsSearching(false);
|
|
}
|
|
};
|
|
|
|
// ── Release detail ───────────────────────────────────────────────────────────
|
|
|
|
const openRelease = async (row: CsdbRow) => {
|
|
setSelectedRow(row);
|
|
setRelease(null);
|
|
setLoadingRelease(true);
|
|
try {
|
|
const res = await fetch(`${CSDB_API}/release/${row.id}`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data: CsdbRelease = await res.json();
|
|
setRelease(data);
|
|
} catch (e: any) {
|
|
toast.error(`Failed to load release: ${e?.message ?? e}`);
|
|
setRelease({ ID: +row.id, Name: row.name, Type: '', ScreenShot: [], DownloadLinks: [] });
|
|
} finally {
|
|
setLoadingRelease(false);
|
|
}
|
|
};
|
|
|
|
// ── Mount ────────────────────────────────────────────────────────────────────
|
|
|
|
const mountOnDevice = async (deviceType: 'drive' | 'meatloaf', key: string) => {
|
|
if (!mountEntry) return;
|
|
const { row, link } = mountEntry;
|
|
setIsMounting(true);
|
|
setMountProgress({ received: 0, total: 0, phase: 'fetching' });
|
|
try {
|
|
await clearDirectory(DOWNLOAD_DIR);
|
|
const url = resolveLink(link.Link);
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await streamFetch(res, p => setMountProgress({ ...p, phase: 'fetching' }));
|
|
const fname = link.Filename || basename(link.Link) || row.name;
|
|
const dest = joinPath(DOWNLOAD_DIR, fname);
|
|
setMountProgress(p => p && { ...p, phase: 'saving' });
|
|
await putFileContents(dest, data);
|
|
if (release?.ScreenShot?.[0]) {
|
|
setMountProgress(p => p && { ...p, phase: 'image' });
|
|
try {
|
|
const imgRes = await fetch(release.ScreenShot[0]);
|
|
if (imgRes.ok) {
|
|
const imgData = await imgRes.arrayBuffer();
|
|
const mainBase = fname.replace(/\.[^.]+$/, '');
|
|
const imgExt = release.ScreenShot[0].split('.').pop()?.split('?')[0]?.toLowerCase() ?? 'png';
|
|
await putFileContents(joinPath(DOWNLOAD_DIR, `${mainBase}.${imgExt}`), imgData);
|
|
}
|
|
} catch { /* non-fatal */ }
|
|
}
|
|
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 = dest;
|
|
delete dev.media_set;
|
|
if (!dev.enabled) dev.enabled = 1;
|
|
setConfig(newConfig);
|
|
setMountEntry(null);
|
|
setSelectedRow(null);
|
|
toast.success(`Downloaded and mounted "${fname}" on device #${key}`);
|
|
} catch (e: any) {
|
|
toast.error(`Mount failed: ${e?.message ?? e}`);
|
|
} finally {
|
|
setIsMounting(false);
|
|
setMountProgress(null);
|
|
}
|
|
};
|
|
|
|
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]);
|
|
|
|
// ── Derived ──────────────────────────────────────────────────────────────────
|
|
|
|
const tagTypes = useMemo(() => {
|
|
const set = new Set<string>();
|
|
for (const r of results) set.add(typeLabel(r.tags));
|
|
return [...set].sort();
|
|
}, [results]);
|
|
|
|
const visibleResults = useMemo(() => {
|
|
const needle = filterText.trim().toLowerCase();
|
|
let list = results.filter(r =>
|
|
(!tagFilter || typeLabel(r.tags) === tagFilter) &&
|
|
(!needle || r.name.toLowerCase().includes(needle))
|
|
);
|
|
list = [...list].sort((a, b) => {
|
|
const cmp = a.name.localeCompare(b.name);
|
|
return sortDir === 'asc' ? cmp : -cmp;
|
|
});
|
|
return list;
|
|
}, [results, tagFilter, filterText, sortDir]);
|
|
|
|
const activeFilters = (filterText ? 1 : 0) + (tagFilter ? 1 : 0);
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<>
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
{/* Panel header — X on the left so it's never behind a top-right camera */}
|
|
<div className="flex-shrink-0 flex items-center gap-2 border-b border-neutral-200/70 px-4">
|
|
<button onClick={onClose} className="p-1.5 my-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors flex-shrink-0" aria-label="Close search">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
<div className="flex items-center gap-2 flex-1 py-3 min-w-0">
|
|
<img src={`${import.meta.env.BASE_URL}assets/favicon.csdbng.png`} className="h-5 flex-shrink-0 object-contain" alt="" aria-hidden="true" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search input */}
|
|
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-200/70">
|
|
<div className="flex items-center gap-1.5 mb-2">
|
|
<div className="relative flex-1">
|
|
{isSearching
|
|
? <Loader2 className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500 animate-spin pointer-events-none" />
|
|
: <button onClick={() => doSearch(query)} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-blue-500 transition-colors" tabIndex={-1} title="Search"><Search className="w-4 h-4" /></button>
|
|
}
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && !isSearching && doSearch(query)}
|
|
placeholder="Search CSDb-ng…"
|
|
inputMode="search"
|
|
enterKeyHint="search"
|
|
className="w-full pl-9 pr-9 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}
|
|
/>
|
|
{query && (
|
|
<button
|
|
onClick={() => { setQuery(''); setResults([]); setHasSearched(false); setSearchError(null); setTagFilter(null); }}
|
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
|
|
title="Clear"
|
|
tabIndex={-1}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{hasSearched && (
|
|
<button
|
|
onClick={() => setShowFilter(v => !v)}
|
|
className={`relative p-1.5 rounded-lg transition-colors flex-shrink-0 ${showFilter ? 'bg-blue-100 text-blue-600' : 'hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600'}`}
|
|
title="Filter & sort results"
|
|
>
|
|
<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>
|
|
)}
|
|
</div>
|
|
|
|
{/* Type filter chips */}
|
|
{tagTypes.length > 1 && (
|
|
<div className="flex gap-1.5 overflow-x-auto pb-0.5" style={{ scrollbarWidth: 'none' }}>
|
|
<button
|
|
onClick={() => setTagFilter(null)}
|
|
className={`flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${tagFilter === null ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'}`}
|
|
>
|
|
All
|
|
</button>
|
|
{tagTypes.map(t => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setTagFilter(tagFilter === t ? null : t)}
|
|
className={`flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${tagFilter === t ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'}`}
|
|
>
|
|
{t}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Filter + sort bar */}
|
|
<div className={`overflow-hidden flex-shrink-0 transition-all duration-200 ease-in-out ${showFilter ? 'max-h-16 opacity-100' : 'max-h-0 opacity-0'}`}>
|
|
<div className="bg-neutral-50 border-b border-neutral-200 px-4 py-2 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 results…"
|
|
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>
|
|
<button
|
|
onClick={() => setSortDir(d => d === 'asc' ? 'desc' : 'asc')}
|
|
className={`text-xs px-2 py-1 rounded border flex-shrink-0 border-blue-400 bg-blue-50 text-blue-700`}
|
|
>
|
|
Name {sortDir === 'asc' ? '↑' : '↓'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div
|
|
ref={scrollRef}
|
|
className="flex-1 overflow-y-auto"
|
|
onScroll={e => { _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }}
|
|
>
|
|
{isSearching && (
|
|
<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>
|
|
)}
|
|
|
|
{!isSearching && searchError && (
|
|
<div className="p-6 text-center">
|
|
<p className="text-sm text-red-500">{searchError}</p>
|
|
<button onClick={() => doSearch(query)} className="mt-2 text-xs text-blue-600 hover:underline">
|
|
Retry
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{!isSearching && !searchError && hasSearched && (
|
|
visibleResults.length > 0 ? (
|
|
<>
|
|
<p className="text-xs text-neutral-400 px-4 py-2 border-b border-neutral-100">
|
|
{visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}
|
|
</p>
|
|
{visibleResults.map(row => (
|
|
<div
|
|
key={row.id}
|
|
className="flex items-stretch border-b border-neutral-100 border-l-2 border-l-transparent hover:bg-blue-50 hover:border-l-blue-400 transition-colors"
|
|
>
|
|
<button
|
|
onClick={() => openRelease(row)}
|
|
className="flex-1 pl-4 pr-2 py-3 flex items-center gap-3 text-left min-w-0"
|
|
>
|
|
<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">{row.name}</span>
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 font-mono flex-shrink-0">{typeLabel(row.tags)}</span>
|
|
</div>
|
|
</div>
|
|
<ChevronRight className="w-4 h-4 text-neutral-300 flex-shrink-0" />
|
|
</button>
|
|
<button
|
|
onClick={() => setActionRow(row)}
|
|
className="px-3 text-neutral-300 hover:text-neutral-600 flex-shrink-0 transition-colors"
|
|
aria-label="Actions"
|
|
>
|
|
<MoreVertical className="w-4 h-4" />
|
|
</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">
|
|
{results.length > 0 ? 'No results match the current filter' : 'No results'}
|
|
</p>
|
|
{results.length > 0 && (filterText || tagFilter) && (
|
|
<button
|
|
onClick={() => { setFilterText(''); setTagFilter(null); }}
|
|
className="mt-2 text-xs text-blue-600 hover:underline"
|
|
>
|
|
Clear filters
|
|
</button>
|
|
)}
|
|
</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 the CSDb database</p>
|
|
<p className="text-xs text-neutral-400">Commodore Scene Database — games, demos, music and more</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions dialog */}
|
|
<Dialog open={actionRow !== null} onOpenChange={open => !open && setActionRow(null)}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader className="overflow-hidden min-w-0">
|
|
<DialogTitle className="sr-only">{actionRow?.name}</DialogTitle>
|
|
<p className="text-lg font-semibold leading-none pr-6 overflow-hidden min-w-0">
|
|
<MarqueeText>{actionRow?.name}</MarqueeText>
|
|
</p>
|
|
<DialogDescription>{actionRow ? typeLabel(actionRow.tags) : ''}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex flex-col gap-2">
|
|
<button
|
|
onClick={() => { openRelease(actionRow!); setActionRow(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"
|
|
>
|
|
<FolderOpen className="w-4 h-4 text-blue-600" /> <span>Browse Downloads</span>
|
|
</button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Release detail dialog */}
|
|
<Dialog open={selectedRow !== null} onOpenChange={open => !open && setSelectedRow(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">{selectedRow?.name}</DialogTitle>
|
|
<p className="text-lg font-semibold leading-none pr-6 overflow-hidden min-w-0">
|
|
<MarqueeText>{selectedRow?.name}</MarqueeText>
|
|
</p>
|
|
<DialogDescription>
|
|
{[release?.Type, selectedRow?.tags.split(' ')[0]?.toUpperCase()].filter(Boolean).join(' · ')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="overflow-y-auto flex-1 min-h-0">
|
|
{loadingRelease && (
|
|
<div className="flex justify-center py-8">
|
|
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
|
|
</div>
|
|
)}
|
|
{!loadingRelease && release?.DownloadLinks.filter(l => isRelativeLink(l.Link)).length === 0 && (
|
|
<p className="text-sm text-neutral-400 text-center py-8">No download links found</p>
|
|
)}
|
|
{!loadingRelease && release && release.DownloadLinks.some(l => isRelativeLink(l.Link)) && (
|
|
<div className="flex flex-col gap-2 py-1">
|
|
{release.DownloadLinks.filter(l => isRelativeLink(l.Link)).map((link, i) => {
|
|
const fname = link.Filename || basename(link.Link) || selectedRow!.name;
|
|
const localPath = joinPath(DOWNLOAD_DIR, fname);
|
|
const isMounted = mountedUrls.has(localPath);
|
|
return (
|
|
<button
|
|
key={i}
|
|
onClick={() => setMountEntry({ row: selectedRow!, link })}
|
|
className={`px-4 py-3 rounded-lg border flex items-center gap-3 text-left w-full transition-colors ${isMounted ? 'border-blue-300 bg-blue-50' : 'border-neutral-200 hover:bg-blue-50 hover:border-blue-300'}`}
|
|
>
|
|
<EntryIcon entry={{ name: fname, type: 'file', path: link.Link, size: 0, lastModified: new Date(), contentType: '' }} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium text-neutral-800 truncate">{fname}</div>
|
|
</div>
|
|
{isMounted && <span className="text-xs text-blue-600 flex-shrink-0">Mounted</span>}
|
|
</button>
|
|
);
|
|
})}
|
|
</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 ? (mountEntry.link.Filename || mountEntry.row.name) : ''}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="overflow-y-auto flex-1 min-h-0">
|
|
{isMounting && (
|
|
<div className="flex flex-col items-center justify-center py-10 gap-4">
|
|
{mountProgress?.phase === 'saving' || mountProgress?.phase === 'image' ? (
|
|
<>
|
|
<Loader2 className="w-7 h-7 text-blue-500 animate-spin" />
|
|
<p className="text-sm text-neutral-500">{mountProgress.phase === 'image' ? 'Saving cover image…' : 'Saving to device…'}</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<p className="text-sm font-medium text-neutral-600">
|
|
{mountProgress?.total
|
|
? `${humanFileSize(mountProgress.received)} / ${humanFileSize(mountProgress.total)}`
|
|
: mountProgress?.received ? humanFileSize(mountProgress.received) : 'Downloading…'}
|
|
</p>
|
|
<div className="w-56 h-1.5 bg-neutral-200 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-blue-500 rounded-full transition-all duration-75"
|
|
style={{ width: mountProgress?.total ? `${Math.min(100, mountProgress.received / mountProgress.total * 100)}%` : '0%' }}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!isMounting && (() => {
|
|
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>
|
|
</>
|
|
);
|
|
}
|