694 lines
32 KiB
TypeScript
694 lines
32 KiB
TypeScript
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<string, string>) {
|
|
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<T>(path: string, query?: Record<string, string>): Promise<T> {
|
|
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<string, string | null> = {
|
|
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 (
|
|
<span className="text-amber-400 text-xs">
|
|
{'★'.repeat(pct)}{'☆'.repeat(5 - pct)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ─── 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<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const [showAqlHelp, setShowAqlHelp] = useState(false);
|
|
|
|
const [query, setQuery] = useState(() => _store.query);
|
|
const [results, setResults] = useState<ContentItem[]>(() => _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<string | null>(null);
|
|
const [categoryFilter, setCategoryFilter] = useState<number | null>(() => _store.categoryFilter);
|
|
|
|
const [categories, setCategories] = useState<CategoryMapping[]>([]);
|
|
const [presets, setPresets] = useState<PresetGroup[]>([]);
|
|
const [activePreset, setActivePreset] = useState<PresetGroup | null>(null);
|
|
|
|
const [selectedItem, setSelectedItem] = useState<ContentItem | null>(null);
|
|
const [entries, setEntries] = useState<ContentEntry[] | null>(null);
|
|
const [loadingEntries, setLoadingEntries] = useState(false);
|
|
|
|
const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null);
|
|
const [downloading, setDownloading] = useState<number | null>(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<CategoryMapping[]>('/search/categories').then(setCategories).catch(() => {});
|
|
leetJson<Preset[]>('/search/aql/presets').then(setPresets).catch(() => {});
|
|
}, []);
|
|
|
|
const categoryName = useMemo(() => {
|
|
const map: Record<number, string> = {};
|
|
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<ContentItem[]>(
|
|
`/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<ContentEntry[] | { contentEntry: ContentEntry[] }>(
|
|
`/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<string>();
|
|
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 (
|
|
<>
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-200/70">
|
|
{/* Search input */}
|
|
<div className="flex gap-2 mb-2">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={e => 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}
|
|
/>
|
|
<button
|
|
onClick={() => setShowAqlHelp(true)}
|
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
|
|
title="AQL search help"
|
|
tabIndex={-1}
|
|
>
|
|
<HelpCircle className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={handleSearch}
|
|
disabled={isSearching}
|
|
className="px-4 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-medium hover:bg-blue-700 disabled:opacity-40 transition-colors"
|
|
>
|
|
{isSearching ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Search'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Presets */}
|
|
{presets.length > 0 && (
|
|
<div className="flex gap-1.5 mb-2 overflow-x-auto pb-0.5 scrollbar-none">
|
|
{presets
|
|
.filter(group => PRESET_PREFIX[group.type] !== null) // hide sort/order for now
|
|
.map((group, i) => (
|
|
<button
|
|
key={`${group.type}-${i}`}
|
|
onClick={() => setActivePreset(group)}
|
|
className="flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium bg-neutral-100 text-neutral-600 hover:bg-neutral-200 transition-colors inline-flex items-center gap-1"
|
|
>
|
|
{group.description}
|
|
<ChevronDown className="w-3 h-3 opacity-60" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Category filter */}
|
|
{categories.length > 0 && (
|
|
<div className="flex gap-1.5 overflow-x-auto pb-0.5 scrollbar-none">
|
|
<button
|
|
onClick={() => { setCategoryFilter(null); if (hasSearched) doSearch(query, null, 0); }}
|
|
className={`flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${categoryFilter === null ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'}`}
|
|
>
|
|
All
|
|
</button>
|
|
{categories.map(c => {
|
|
const name = (c.name ?? c.title ?? String(c.id)) as string;
|
|
return (
|
|
<button
|
|
key={c.id}
|
|
onClick={() => { setCategoryFilter(c.id); if (hasSearched) doSearch(query, c.id, 0); }}
|
|
className={`flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${categoryFilter === c.id ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'}`}
|
|
>
|
|
{name}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div
|
|
ref={scrollRef}
|
|
className="flex-1 overflow-y-auto"
|
|
onScroll={e => { _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }}
|
|
>
|
|
{isSearching && !hasSearched && (
|
|
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
|
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
|
<p className="text-sm text-neutral-500">Searching…</p>
|
|
</div>
|
|
)}
|
|
|
|
{searchError && (
|
|
<div className="p-6 text-center">
|
|
<p className="text-sm text-red-500">{searchError}</p>
|
|
<button onClick={handleSearch} className="mt-2 inline-flex items-center gap-1 text-xs text-blue-600">
|
|
<RefreshCw className="w-3 h-3" /> Retry
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{!searchError && hasSearched && (
|
|
<>
|
|
{results.length > 0 ? (
|
|
<>
|
|
<p className="text-xs text-neutral-400 px-4 py-2 border-b border-neutral-100 flex items-center gap-1.5">
|
|
{isSearching && <Loader2 className="w-3 h-3 animate-spin" />}
|
|
{results.length} result{results.length !== 1 ? 's' : ''}{hasMore ? '+' : ''}
|
|
</p>
|
|
{results.map(item => {
|
|
const catLabel = categoryName[item.category] ?? `Cat ${item.category}`;
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
onClick={() => openItem(item)}
|
|
className="w-full pl-4 pr-4 py-3 flex items-center gap-3 border-b border-neutral-100 border-l-2 border-l-transparent transition-colors hover:bg-blue-50 hover:border-l-blue-400 text-left"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-sm font-medium text-neutral-900 truncate">{item.name}</span>
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 font-mono flex-shrink-0">{catLabel}</span>
|
|
{item.place === 1 && <Trophy className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />}
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
|
{item.group && (
|
|
<span className="text-xs text-neutral-400 flex items-center gap-0.5">
|
|
<Users className="w-3 h-3" />{item.group}
|
|
</span>
|
|
)}
|
|
{item.handle && !item.group && (
|
|
<span className="text-xs text-neutral-400">{item.handle}</span>
|
|
)}
|
|
{item.year && (
|
|
<span className="text-xs text-neutral-400 flex items-center gap-0.5">
|
|
<Calendar className="w-3 h-3" />{item.year}
|
|
</span>
|
|
)}
|
|
{item.event && (
|
|
<span className="text-xs text-neutral-400 truncate">{item.event}</span>
|
|
)}
|
|
{item.place && (
|
|
<span className="text-xs text-neutral-400">#{item.place}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex-shrink-0 flex flex-col items-end gap-0.5">
|
|
{item.siteRating != null && <RatingStars value={item.siteRating} />}
|
|
<ChevronRight className="w-4 h-4 text-neutral-300" />
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
{hasMore && (
|
|
<div className="p-4 flex justify-center">
|
|
<button
|
|
onClick={handleLoadMore}
|
|
disabled={isLoadingMore}
|
|
className="px-6 py-2 bg-neutral-100 rounded-xl text-sm text-neutral-600 hover:bg-neutral-200 disabled:opacity-40 inline-flex items-center gap-2"
|
|
>
|
|
{isLoadingMore ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
|
Load more
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="py-16 text-center">
|
|
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" />
|
|
<p className="text-sm text-neutral-500">No results</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{!hasSearched && !isSearching && (
|
|
<div className="py-16 text-center px-6">
|
|
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" />
|
|
<p className="text-sm font-medium text-neutral-600 mb-1">Search the Assembly64 database</p>
|
|
<p className="text-xs text-neutral-400">
|
|
Use AQL syntax: <code className="bg-neutral-100 px-1 rounded">name:manic*</code>,{' '}
|
|
<code className="bg-neutral-100 px-1 rounded">group:triad</code>,{' '}
|
|
<code className="bg-neutral-100 px-1 rounded">year:1983</code>
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Item entries dialog */}
|
|
<Dialog open={selectedItem !== null} onOpenChange={open => !open && setSelectedItem(null)}>
|
|
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
|
|
<DialogHeader className="flex-shrink-0 overflow-hidden min-w-0">
|
|
<DialogTitle className="sr-only">{selectedItem?.name}</DialogTitle>
|
|
<p className="text-lg font-semibold leading-none pr-6 overflow-hidden min-w-0">
|
|
<MarqueeText>{selectedItem?.name}</MarqueeText>
|
|
</p>
|
|
<DialogDescription>
|
|
{[selectedItem?.group, selectedItem?.year, selectedItem?.event].filter(Boolean).join(' · ')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="overflow-y-auto flex-1 min-h-0">
|
|
{loadingEntries && (
|
|
<div className="flex justify-center py-8">
|
|
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
|
|
</div>
|
|
)}
|
|
{!loadingEntries && entries?.length === 0 && (
|
|
<p className="text-sm text-neutral-400 text-center py-8">No files found</p>
|
|
)}
|
|
{!loadingEntries && entries && entries.length > 0 && (
|
|
<div className="flex flex-col gap-2 py-1">
|
|
{entries.map(entry => {
|
|
const fname = entryFilename(entry);
|
|
const isMounted = mountedUrls.has(downloadUrl(selectedItem!, entry));
|
|
return (
|
|
<div
|
|
key={entry.id}
|
|
className={`px-4 py-3 rounded-lg border flex items-center gap-3 ${isMounted ? 'border-blue-300 bg-blue-50' : 'border-neutral-200'}`}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium text-neutral-800 truncate">{fname}</div>
|
|
<div className="text-xs text-neutral-400">{humanFileSize(entry.size)}</div>
|
|
</div>
|
|
<button
|
|
onClick={() => downloadToSd(selectedItem!, entry)}
|
|
disabled={downloading === entry.id}
|
|
className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 disabled:opacity-40"
|
|
title="Download to /sd/downloads"
|
|
>
|
|
{downloading === entry.id
|
|
? <Loader2 className="w-4 h-4 animate-spin" />
|
|
: <Download className="w-4 h-4" />}
|
|
</button>
|
|
<button
|
|
onClick={() => setMountEntry({ item: selectedItem!, entry })}
|
|
className={`p-1.5 rounded-lg ${isMounted ? 'text-blue-600' : 'hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600'}`}
|
|
title="Mount on virtual drive"
|
|
>
|
|
<HardDrive className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Mount device picker */}
|
|
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}>
|
|
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
|
|
<DialogHeader className="flex-shrink-0">
|
|
<DialogTitle>Mount on Virtual Drive</DialogTitle>
|
|
<DialogDescription className="truncate">
|
|
{mountEntry ? entryFilename(mountEntry.entry) : ''}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="overflow-y-auto flex-1 min-h-0">
|
|
{(() => {
|
|
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 <p className="text-sm text-neutral-500 text-center py-4">No drive devices found in config.</p>;
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
{devices.map(dev => (
|
|
<button
|
|
key={`${dev.type}-${dev.key}`}
|
|
onClick={() => mountOnDevice(dev.type, dev.key)}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 flex items-center gap-3"
|
|
>
|
|
<HardDrive className={`w-5 h-5 flex-shrink-0 ${dev.enabled ? 'text-blue-500' : 'text-neutral-400'}`} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="font-medium text-sm">Device #{dev.key}</div>
|
|
{(dev.base_url || dev.url) && (
|
|
<div
|
|
className="text-xs text-neutral-500 overflow-hidden whitespace-nowrap"
|
|
style={{ direction: 'rtl', textOverflow: 'ellipsis' }}
|
|
title={[dev.base_url, dev.url].filter(Boolean).join('')}
|
|
>
|
|
<span style={{ direction: 'ltr', unicodeBidi: 'embed' }}>
|
|
{[dev.base_url, dev.url].filter(Boolean).join('')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{!dev.enabled && <span className="text-xs text-neutral-400 flex-shrink-0">disabled</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* AQL help dialog */}
|
|
<Dialog open={showAqlHelp} onOpenChange={setShowAqlHelp}>
|
|
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
|
|
<DialogHeader className="flex-shrink-0">
|
|
<DialogTitle>AQL Search Terms</DialogTitle>
|
|
<DialogDescription>Tap a term to insert it into the search field.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="overflow-y-auto flex-1 min-h-0 flex flex-col gap-1 py-1">
|
|
{AQL_TERMS.map(({ term, label, example, description }) => (
|
|
<button
|
|
key={term}
|
|
onClick={() => {
|
|
const trimmed = query.trimEnd();
|
|
const next = trimmed ? `${trimmed} ${term}` : term;
|
|
setQuery(next);
|
|
setShowAqlHelp(false);
|
|
setTimeout(() => {
|
|
const el = inputRef.current;
|
|
if (!el) return;
|
|
el.focus();
|
|
el.setSelectionRange(next.length, next.length);
|
|
}, 50);
|
|
}}
|
|
className="w-full text-left px-4 py-3 rounded-lg border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 transition-colors"
|
|
>
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="font-mono text-sm font-semibold text-blue-700">{term}</span>
|
|
<span className="text-xs text-neutral-400">{label}</span>
|
|
<span className="ml-auto font-mono text-xs text-neutral-400">{example}</span>
|
|
</div>
|
|
<p className="text-xs text-neutral-500 mt-0.5">{description}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Preset values dialog */}
|
|
<Dialog open={activePreset !== null} onOpenChange={open => !open && setActivePreset(null)}>
|
|
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
|
|
<DialogHeader className="flex-shrink-0">
|
|
<DialogTitle>{activePreset?.description ?? 'Preset'}</DialogTitle>
|
|
<DialogDescription>Tap a value to add it to your search.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="overflow-y-auto flex-1 min-h-0 flex flex-col gap-1 py-1">
|
|
{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 (
|
|
<button
|
|
key={`${v.aqlKey}-${i}`}
|
|
onClick={() => applyPreset(activePreset, v)}
|
|
className="w-full text-left px-4 py-2.5 rounded-lg border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 transition-colors flex items-center gap-2"
|
|
>
|
|
<span className="text-sm text-neutral-800 flex-1 truncate">{label}</span>
|
|
<span className="font-mono text-xs text-neutral-400 flex-shrink-0">{token}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|