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://hackerswithstyle.se/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)); // The server whitelists "Client-Id: Ultimate" and the "Assembly Query" // User-Agent (matching the native Assembly64 client). Requests with the // wrong identifiers get rejected: HTTP 464 for unknown Client-Id, HTTP // 463 for a missing/foreign User-Agent. The server's CORS preflight // allows both headers, so the browser accepts this request. 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; // 'repo' | 'category' | 'subcat' | 'rating' | 'type' | 'date' | 'latest' | 'sort' | 'order' description: string; // Human-readable group label shown on the chip values: PresetValue[]; } // Each preset group maps to a search-token prefix. 'sort' and 'order' are not // filters (they're result-ordering directives) so they get an empty prefix // marker and the renderer skips them. const PRESET_PREFIX: Record = { repo: 'repo', category: 'category', subcat: 'subcat', rating: 'rating', type: 'type', date: 'year', latest: 'added', sort: null, order: null, }; // ─── Props ──────────────────────────────────────────────────────────────────── interface SearchAssembly64Props { 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 SearchAssembly64({ config, setConfig, onClose: _onClose }: SearchAssembly64Props) { 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); // Build an AQL token for a preset value and append/replace it in the query. // Tokens that already contain a colon (e.g. 'subcat:c64comdemos') are // inserted verbatim; raw values get the group's prefix. const applyPreset = (group: PresetGroup, value: PresetValue) => { const prefix = PRESET_PREFIX[group.type]; setActivePreset(null); if (prefix === null || prefix === undefined) return; // sort/order: not a filter // Some aqlKeys are self-describing ("subcat:c64comdemos"); use them // verbatim. Otherwise prepend the group's prefix. const token = value.aqlKey.includes(':') ? value.aqlKey : `${prefix}:${value.aqlKey}`; const trimmed = query.trim(); const next = trimmed ? `${trimmed} ${token}` : token; setQuery(next); doSearch(next); doSearch(q, categoryFilter, 0); }; // ── Item entries ───────────────────────────────────────────────────────────── const openItem = async (item: ContentItem) => { setSelectedItem(item); setEntries(null); setLoadingEntries(true); try { // The /search/entries endpoint now returns { contentEntry: [...] } // (matching the API's ContentEntryContainerV2 schema), not a bare array. 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 */}
{/* Search input */}
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 */} {presets.length > 0 && (
{presets .filter(group => PRESET_PREFIX[group.type] !== null) // hide sort/order for now .map((group, i) => ( ))}
)} {/* Category filter */} {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 Assembly64 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 ( ); })}
); }