import { useEffect, useMemo, useRef, useState } from 'react'; import { Search, Loader2, HardDrive, Download, ChevronRight } from 'lucide-react'; import { toast } from 'sonner'; import { basename, joinPath, putFileContents } from '../webdav'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; import { MarqueeText } from './ui/marquee-text'; // ─── API ────────────────────────────────────────────────────────────────────── const CSDB_API = 'https://api.idolpx.com/csdb'; const CSDB_HOST = 'https://csdb.dk'; const DOWNLOAD_DIR = '/sd/downloads'; function resolveLink(link: string): string { if (link.startsWith('http://') || link.startsWith('https://')) return link; return CSDB_HOST + link; } // ─── 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[]; } // ─── 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, }; // ─── 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: _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 [mountEntry, setMountEntry] = useState<{ row: CsdbRow; link: CsdbDownloadLink } | null>(null); const [downloading, setDownloading] = useState(null); useEffect(() => { _store.query = query; _store.results = results; _store.hasSearched = hasSearched; _store.tagFilter = tagFilter; }, [query, results, hasSearched, tagFilter]); 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); 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); } }; // ── Download to SD ─────────────────────────────────────────────────────────── const downloadToSd = async (row: CsdbRow, link: CsdbDownloadLink) => { const url = resolveLink(link.Link); setDownloading(url); try { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.arrayBuffer(); const fname = link.Filename || basename(link.Link) || row.name; const dest = joinPath(DOWNLOAD_DIR, fname); 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 { row, link } = 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 = resolveLink(link.Link); delete dev.media_set; if (!dev.enabled) dev.enabled = 1; setConfig(newConfig); setMountEntry(null); toast.success(`Mounted "${link.Filename || row.name}" on ${deviceType} #${key}`); }; 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(() => tagFilter ? results.filter(r => typeLabel(r.tags) === tagFilter) : results, [results, tagFilter] ); // ── Render ──────────────────────────────────────────────────────────────────── return ( <>
{/* Header */}
setQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && !isSearching && doSearch(query)} placeholder="Search CSDb…" 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} />
{/* Type filter chips */} {tagTypes.length > 1 && (
{tagTypes.map(t => ( ))}
)}
{/* Body */}
{ _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }} > {isSearching && (

Searching…

)} {!isSearching && searchError && (

{searchError}

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

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

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

No results

) )} {!isSearching && !hasSearched && (

Search the CSDb database

Commodore Scene Database — games, demos, music and more

)}
{/* Release detail dialog */} !open && setSelectedRow(null)}> {selectedRow?.name}

{selectedRow?.name}

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

No download links found

)} {!loadingRelease && release && release.DownloadLinks.length > 0 && (
{release.DownloadLinks.map((link, i) => { const url = resolveLink(link.Link); const isMounted = mountedUrls.has(url); const fname = link.Filename || basename(link.Link) || selectedRow!.name; return (
{fname}
{link.Downloads > 0 ? `${link.Downloads.toLocaleString()} downloads` : link.Status}
); })}
)}
{/* Mount device picker */} !open && setMountEntry(null)}> Mount on Virtual Drive {mountEntry ? (mountEntry.link.Filename || mountEntry.row.name) : ''}
{(() => { 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 => ( ))}
); })()}
); }