import { useEffect, useMemo, useRef, useState } from 'react'; import { Search, Loader2, HardDrive, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X, SlidersHorizontal, MoreVertical, FolderOpen } 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'; import { EntryIcon } from './MediaEntry'; // ─── 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)); return fetch(url.toString(), { headers: { 'User-Agent': 'Assembly Query', 'Client-Id': 'meatloaf', }, }); } 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, }; type SortField = 'name' | 'year' | 'rating'; type SortDir = 'asc' | 'desc'; // ─── 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, showFilter: false, filterText: '', sortField: 'name' as SortField, sortDir: 'asc' as SortDir, }; // ─── Helpers ────────────────────────────────────────────────────────────────── function entryFilename(e: ContentEntry): string { return basename(e.path) || e.path; } 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 }: 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 [actionItem, setActionItem] = useState(null); const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null); const [isMounting, setIsMounting] = useState(false); const [showFilter, setShowFilter] = useState(() => _store.showFilter); const [filterText, setFilterText] = useState(() => _store.filterText); const [sortField, setSortField] = useState(() => _store.sortField); const [sortDir, setSortDir] = useState(() => _store.sortDir); useEffect(() => { _store.query = query; _store.results = results; _store.offset = offset; _store.hasMore = hasMore; _store.hasSearched = hasSearched; _store.categoryFilter = categoryFilter; _store.showFilter = showFilter; _store.filterText = filterText; _store.sortField = sortField; _store.sortDir = sortDir; }, [query, results, offset, hasMore, hasSearched, categoryFilter, showFilter, filterText, sortField, sortDir]); 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 = () => { setFilterText(''); 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); }; // ── Filter / sort ──────────────────────────────────────────────────────────── const toggleSort = (field: SortField) => { if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSortField(field); setSortDir('asc'); } }; const visibleResults = useMemo(() => { const needle = filterText.trim().toLowerCase(); let list = needle ? results.filter(r => r.name.toLowerCase().includes(needle) || (r.group?.toLowerCase().includes(needle)) || (r.handle?.toLowerCase().includes(needle)) ) : results; list = [...list].sort((a, b) => { let cmp: number; if (sortField === 'year') cmp = (a.year ?? 0) - (b.year ?? 0); else if (sortField === 'rating') cmp = (a.siteRating ?? 0) - (b.siteRating ?? 0); else cmp = a.name.localeCompare(b.name); return sortDir === 'asc' ? cmp : -cmp; }); return list; }, [results, filterText, sortField, sortDir]); const activeFilters = filterText ? 1 : 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); } }; // ── Mount ──────────────────────────────────────────────────────────────────── const mountOnDevice = async (deviceType: 'drive' | 'meatloaf', key: string) => { if (!mountEntry) return; const { item, entry } = mountEntry; setIsMounting(true); try { const res = await leetFetch(`/search/bin/${item.id}/${item.category}/${entry.id}`); 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); 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); setSelectedItem(null); toast.success(`Downloaded and mounted "${entryFilename(entry)}" on device #${key}`); } catch (e: any) { toast.error(`Mount failed: ${e?.message ?? e}`); } finally { setIsMounting(false); } }; 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 ( <>
{/* Panel header — X on the left so it's never behind a top-right camera */}
Assembly64
{hasSearched && ( )}
{/* Search input + presets + categories */}
setQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && !isSearching && handleSearch()} placeholder="name:manic* group:ultimate year:1983…" 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} />
{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 ( ); })}
)}
{/* 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 && ( )}
{(['name', 'year', 'rating'] as SortField[]).map(f => ( ))}
{/* Body */}
{ _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }} > {isSearching && !hasSearched && (

Searching…

)} {searchError && (

{searchError}

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

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

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

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

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

Search the Assembly64 database

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

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

{actionItem?.name}

{[actionItem?.group, actionItem?.year, actionItem?.event].filter(Boolean).join(' · ')}
{/* 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 localPath = joinPath(DOWNLOAD_DIR, fname); const isMounted = mountedUrls.has(localPath); return ( ); })}
)}
{/* Mount device picker */} !open && setMountEntry(null)}> Mount on Virtual Drive {mountEntry ? entryFilename(mountEntry.entry) : ''}
{isMounting && (

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 => ( ))}
); })()}
{/* 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 ( ); })}
); }