From 48f75a1acca4da2938bf8fd1de328eca2e5d0254 Mon Sep 17 00:00:00 2001 From: Jaime Idolpx Date: Mon, 15 Jun 2026 00:04:23 -0400 Subject: [PATCH] feat: add SearchCommoServe and SearchCSDbNG components to SearchPane - Introduced SearchCommoServe component for querying CommoServe database. - Added SearchCSDbNG component for querying CSDb database. - Updated SearchPane to include new tabs for CommoServe and CSDb. - Enhanced state management to accommodate additional search tabs. --- src/app/App.tsx | 4 +- src/app/components/SearchCSDbNG.tsx | 432 +++++++++++++++ src/app/components/SearchCommoServe.tsx | 675 ++++++++++++++++++++++++ src/app/components/SearchPane.tsx | 37 +- 4 files changed, 1140 insertions(+), 8 deletions(-) create mode 100644 src/app/components/SearchCSDbNG.tsx create mode 100644 src/app/components/SearchCommoServe.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index 70f3bf2..187c8ad 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -55,7 +55,7 @@ export default function App() { const [currentPage, setCurrentPage] = useState('status'); const { config, setConfig, saveStatus, pendingCount, flushNow, reload } = useSettings(); const [showSearch, setShowSearch] = useState(false); - const [searchInitialTab, setSearchInitialTab] = useState<0 | 1 | undefined>(undefined); + const [searchInitialTab, setSearchInitialTab] = useState<0 | 1 | 2 | 3 | undefined>(undefined); const [devicesOpenId, setDevicesOpenId] = useState(null); const [isFullscreen, setIsFullscreen] = useState(false); const [fileManagerInitialPath, setFileManagerInitialPath] = useState(undefined); @@ -119,6 +119,8 @@ export default function App() {
} label="Media Manager" onClick={() => { setFileManagerInitialPath(undefined); setCurrentPage('file-manager'); }} /> } label="Assembly64" onClick={() => { setSearchInitialTab(1); setShowSearch(true); }} /> + } label="CommoServe" onClick={() => { setSearchInitialTab(2); setShowSearch(true); }} /> + } label="CSDb" onClick={() => { setSearchInitialTab(3); setShowSearch(true); }} /> } label="Print Manager" onClick={() => setCurrentPage('print-manager')} /> } label="Serial Console" onClick={() => setCurrentPage('serial-console')} /> } label="Short Codes" onClick={() => setCurrentPage('serial-console')} /> diff --git a/src/app/components/SearchCSDbNG.tsx b/src/app/components/SearchCSDbNG.tsx new file mode 100644 index 0000000..3720071 --- /dev/null +++ b/src/app/components/SearchCSDbNG.tsx @@ -0,0 +1,432 @@ +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 => ( + + ))} +
+ ); + })()} +
+
+
+ + ); +} diff --git a/src/app/components/SearchCommoServe.tsx b/src/app/components/SearchCommoServe.tsx new file mode 100644 index 0000000..1f2eed1 --- /dev/null +++ b/src/app/components/SearchCommoServe.tsx @@ -0,0 +1,675 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle } 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://commoserve.files.commodore.net/leet'; +const PAGE_SIZE = 50; +const DOWNLOAD_DIR = '/sd/downloads'; + +function leetFetch(path: string, query?: Record) { + 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': 'Ultimate', + 'User-Agent': 'Assembly Query', + }, + }); +} + +async function leetJson(path: string, query?: Record): Promise { + 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 PresetValue { + aqlKey: string; + name?: string; + id?: number; + [k: string]: unknown; +} + +interface PresetGroup { + type: string; + description: string; + values: PresetValue[]; +} + +const PRESET_PREFIX: Record = { + repo: 'repo', + category: 'category', + subcat: 'subcat', + rating: 'rating', + type: 'type', + date: 'year', + latest: 'added', + sort: null, + order: null, +}; + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface SearchCommoServeProps { + 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 ( + + {'★'.repeat(pct)}{'☆'.repeat(5 - pct)} + + ); +} + +// ─── AQL reference ─────────────────────────────────────────────────────────── + +const AQL_TERMS = [ + { term: 'name:', label: 'Name', example: 'name:manic*', description: 'Title of the release. Supports wildcards (*).' }, + { term: 'group:', label: 'Group', example: 'group:triad', description: 'Group or organization name.' }, + { term: 'handle:', label: 'Handle', example: 'handle:jco', description: 'Author or creator handle.' }, + { term: 'year:', label: 'Year', example: 'year:1983', description: 'Release year.' }, + { term: 'event:', label: 'Event', example: 'event:assembly', description: 'Party or event name.' }, + { term: 'country:', label: 'Country', example: 'country:SE', description: 'Country code (ISO 3166-1 alpha-2).' }, + { term: 'category:', label: 'Category', example: 'category:1', description: 'Category ID number.' }, + { term: 'compo:', label: 'Compo', example: 'compo:demo', description: 'Competition or compo type.' }, + { term: 'place:', label: 'Place', example: 'place:1', description: 'Placement in competition (1 = winner).' }, + { term: 'rating:', label: 'Rating', example: 'rating:9', description: 'Internal rating value.' }, + { term: 'siteRating:', label: 'Site Rating', example: 'siteRating:8', description: 'Site (user) rating value.' }, +] as const; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export default function SearchCommoServe({ config, setConfig, onClose: _onClose }: SearchCommoServeProps) { + const scrollRef = useRef(null); + const inputRef = useRef(null); + const [showAqlHelp, setShowAqlHelp] = useState(false); + + const [query, setQuery] = useState(() => _store.query); + const [results, setResults] = useState(() => _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(null); + const [categoryFilter, setCategoryFilter] = useState(() => _store.categoryFilter); + + const [categories, setCategories] = useState([]); + const [presets, setPresets] = useState([]); + const [activePreset, setActivePreset] = useState(null); + + const [selectedItem, setSelectedItem] = useState(null); + const [entries, setEntries] = useState(null); + const [loadingEntries, setLoadingEntries] = useState(false); + + const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null); + const [downloading, setDownloading] = useState(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('/search/categories').then(setCategories).catch(() => {}); + leetJson('/search/aql/presets').then(setPresets).catch(() => {}); + }, []); + + const categoryName = useMemo(() => { + const map: Record = {}; + 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( + `/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 = (group: PresetGroup, value: PresetValue) => { + const prefix = PRESET_PREFIX[group.type]; + setActivePreset(null); + if (prefix === null || prefix === undefined) return; + const token = value.aqlKey.includes(':') ? value.aqlKey : `${prefix}:${value.aqlKey}`; + const trimmed = query.trim(); + const next = trimmed ? `${trimmed} ${token}` : token; + setQuery(next); + doSearch(next, categoryFilter, 0); + }; + + // ── Item entries ───────────────────────────────────────────────────────────── + + const openItem = async (item: ContentItem) => { + setSelectedItem(item); + setEntries(null); + setLoadingEntries(true); + try { + const data = await leetJson( + `/search/entries/${item.id}/${item.category}`, + ); + const list = Array.isArray(data) ? data : (data.contentEntry ?? []); + setEntries(list); + } 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(); + 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 ( + <> +
+ {/* Header */} +
+
+
+ + setQuery(e.target.value)} + onKeyDown={e => e.key === 'Enter' && !isSearching && handleSearch()} + placeholder="name:manic* group:ultimate year:1983…" + 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} + /> + +
+ +
+ + {presets.length > 0 && ( +
+ {presets + .filter(group => PRESET_PREFIX[group.type] !== null) + .map((group, i) => ( + + ))} +
+ )} + + {categories.length > 0 && ( +
+ + {categories.map(c => { + const name = (c.name ?? c.title ?? String(c.id)) as string; + return ( + + ); + })} +
+ )} +
+ + {/* Body */} +
{ _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }} + > + {isSearching && !hasSearched && ( +
+ +

Searching…

+
+ )} + + {searchError && ( +
+

{searchError}

+ +
+ )} + + {!searchError && hasSearched && ( + <> + {results.length > 0 ? ( + <> +

+ {isSearching && } + {results.length} result{results.length !== 1 ? 's' : ''}{hasMore ? '+' : ''} +

+ {results.map(item => { + const catLabel = categoryName[item.category] ?? `Cat ${item.category}`; + return ( + + ); + })} + + {hasMore && ( +
+ +
+ )} + + ) : ( +
+ +

No results

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

Search the CommoServe database

+

+ Use AQL syntax: name:manic*,{' '} + group:triad,{' '} + year:1983 +

+
+ )} +
+
+ + {/* Item entries dialog */} + !open && setSelectedItem(null)}> + + + {selectedItem?.name} +

+ {selectedItem?.name} +

+ + {[selectedItem?.group, selectedItem?.year, selectedItem?.event].filter(Boolean).join(' · ')} + +
+ +
+ {loadingEntries && ( +
+ +
+ )} + {!loadingEntries && entries?.length === 0 && ( +

No files found

+ )} + {!loadingEntries && entries && entries.length > 0 && ( +
+ {entries.map(entry => { + const fname = entryFilename(entry); + const isMounted = mountedUrls.has(downloadUrl(selectedItem!, entry)); + return ( +
+
+
{fname}
+
{humanFileSize(entry.size)}
+
+ + +
+ ); + })} +
+ )} +
+
+
+ + {/* Mount device picker */} + !open && setMountEntry(null)}> + + + Mount on Virtual Drive + + {mountEntry ? entryFilename(mountEntry.entry) : ''} + + +
+ {(() => { + 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 => ( + + ))} +
+ ); + })()} +
+
+
+ + {/* AQL help dialog */} + + + + AQL Search Terms + Tap a term to insert it into the search field. + +
+ {AQL_TERMS.map(({ term, label, example, description }) => ( + + ))} +
+
+
+ + {/* Preset values dialog */} + !open && setActivePreset(null)}> + + + {activePreset?.description ?? 'Preset'} + Tap a value to add it to your search. + +
+ {activePreset?.values.map((v, i) => { + const label = v.name ?? v.aqlKey; + const prefix = PRESET_PREFIX[activePreset.type]; + const token = v.aqlKey.includes(':') ? v.aqlKey : `${prefix}:${v.aqlKey}`; + return ( + + ); + })} +
+
+
+ + ); +} diff --git a/src/app/components/SearchPane.tsx b/src/app/components/SearchPane.tsx index 847b351..79e2ac5 100644 --- a/src/app/components/SearchPane.tsx +++ b/src/app/components/SearchPane.tsx @@ -3,6 +3,8 @@ import { X, Loader2, Database } from 'lucide-react'; import { motion } from 'motion/react'; import SearchLocal from './SearchLocal'; import SearchAssembly64 from './SearchAssembly64'; +import SearchCommoServe from './SearchCommoServe'; +import SearchCSDbNG from './SearchCSDbNG'; import { humanFileSize } from '../webdav'; import { openLocateDb, @@ -14,18 +16,21 @@ import { interface SearchPaneProps { config: any; setConfig: (c: any) => void; - initialTab?: 0 | 1; + initialTab?: 0 | 1 | 2 | 3; onClose: () => void; onOpenFolder: (path: string) => void; } -const TABS = ['Local', 'Assembly64'] as const; +const TABS = ['Local', 'Assembly64', 'CommoServe', 'CSDb'] as const; -let _lastTab: 0 | 1 = (localStorage.getItem('search.tab') === '1' ? 1 : 0); +let _lastTab: 0 | 1 | 2 | 3 = (() => { + const v = parseInt(localStorage.getItem('search.tab') ?? '0', 10); + return (v >= 0 && v <= 3 ? v : 0) as 0 | 1 | 2 | 3; +})(); export default function SearchPane({ config, setConfig, initialTab, onClose, onOpenFolder }: SearchPaneProps) { const panelRef = useRef(null); - const [activeTab, setActiveTab] = useState<0 | 1>(initialTab ?? _lastTab); + const [activeTab, setActiveTab] = useState<0 | 1 | 2 | 3>(initialTab ?? _lastTab); // Subscribe to the locate-database load pipeline so the user can see the // ~17 MB /sd/.locate download happening on the panel itself, not buried @@ -55,7 +60,7 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO el.scrollLeft = tab * el.clientWidth; }, []); // eslint-disable-line react-hooks/exhaustive-deps - const scrollToTab = (idx: 0 | 1) => { + const scrollToTab = (idx: 0 | 1 | 2 | 3) => { const el = panelRef.current; if (!el) return; el.scrollTo({ left: idx * el.clientWidth, behavior: 'smooth' }); @@ -64,7 +69,7 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO const handleScroll = () => { const el = panelRef.current; if (!el) return; - const idx = Math.round(el.scrollLeft / el.clientWidth) as 0 | 1; + const idx = Math.round(el.scrollLeft / el.clientWidth) as 0 | 1 | 2 | 3; setActiveTab(idx); _lastTab = idx; localStorage.setItem('search.tab', String(idx)); @@ -93,7 +98,7 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO {TABS.map((label, i) => (
+ + {/* CommoServe panel */} +
+ +
+ + {/* CSDb panel */} +
+ +