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(null); const [query, setQuery] = useState(() => _store.query); const [results, setResults] = useState(() => _store.results); const [hasSearched, setHasSearched] = useState(() => _store.hasSearched); const [isSearching, setIsSearching] = useState(false); const [searchError, setSearchError] = useState(null); const [tagFilter, setTagFilter] = useState(() => _store.tagFilter); const [selectedRow, setSelectedRow] = useState(null); const [release, setRelease] = useState(null); const [loadingRelease, setLoadingRelease] = useState(false); const [actionRow, setActionRow] = useState(null); const [mountEntry, setMountEntry] = useState<{ row: CsdbRow; link: CsdbDownloadLink } | null>(null); const [isMounting, setIsMounting] = useState(false); const [mountProgress, setMountProgress] = useState(null); const [showFilter, setShowFilter] = useState(() => _store.showFilter); const [filterText, setFilterText] = useState(() => _store.filterText); const [sortDir, setSortDir] = useState(() => _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(); 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(); 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 ( <>
{/* Panel header — X on the left so it's never behind a top-right camera */}
{/* Search input */}
{isSearching ? : } 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 && ( )}
{hasSearched && ( )}
{/* Type filter chips */} {tagTypes.length > 1 && (
{tagTypes.map(t => ( ))}
)}
{/* Filter + sort bar */}
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 && ( )}
{/* Body */}
{ _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }} > {isSearching && (

Searching…

)} {!isSearching && searchError && (

{searchError}

)} {!isSearching && !searchError && hasSearched && ( visibleResults.length > 0 ? ( <>

{visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}

{visibleResults.map(row => (
))} ) : (

{results.length > 0 ? 'No results match the current filter' : 'No results'}

{results.length > 0 && (filterText || tagFilter) && ( )}
) )} {!isSearching && !hasSearched && (

Search the CSDb database

Commodore Scene Database — games, demos, music and more

)}
{/* Actions dialog */} !open && setActionRow(null)}> {actionRow?.name}

{actionRow?.name}

{actionRow ? typeLabel(actionRow.tags) : ''}
{/* Release detail dialog */} !open && setSelectedRow(null)}> {selectedRow?.name}

{selectedRow?.name}

{[release?.Type, selectedRow?.tags.split(' ')[0]?.toUpperCase()].filter(Boolean).join(' · ')}
{loadingRelease && (
)} {!loadingRelease && release?.DownloadLinks.filter(l => isRelativeLink(l.Link)).length === 0 && (

No download links found

)} {!loadingRelease && release && release.DownloadLinks.some(l => isRelativeLink(l.Link)) && (
{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 ( ); })}
)}
{/* Mount device picker */} !open && setMountEntry(null)}> Mount on Virtual Drive {mountEntry ? (mountEntry.link.Filename || mountEntry.row.name) : ''}
{isMounting && (
{mountProgress?.phase === 'saving' || mountProgress?.phase === 'image' ? ( <>

{mountProgress.phase === 'image' ? 'Saving cover image…' : 'Saving to device…'}

) : ( <>

{mountProgress?.total ? `${humanFileSize(mountProgress.received)} / ${humanFileSize(mountProgress.total)}` : mountProgress?.received ? humanFileSize(mountProgress.received) : 'Downloading…'}

)}
)} {!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

No drive devices found in config.

; return (
{devices.map(dev => ( ))}
); })()}
); }