feat(SearchCommoServe): refactor search logic and enhance array extraction functionality

This commit is contained in:
Jaime Idolpx 2026-06-15 15:56:03 -04:00
parent e3f7ba9c19
commit 936dc5db12

View File

@ -4,6 +4,7 @@ import { toast } from 'sonner';
import { humanFileSize, basename, joinPath, putFileContents } from '../webdav'; import { humanFileSize, basename, joinPath, putFileContents } from '../webdav';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
import { MarqueeText } from './ui/marquee-text'; import { MarqueeText } from './ui/marquee-text';
import { EntryIcon } from './MediaEntry';
// ─── API ────────────────────────────────────────────────────────────────────── // ─── API ──────────────────────────────────────────────────────────────────────
@ -16,7 +17,6 @@ function leetFetch(path: string, query?: Record<string, string>) {
if (query) Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v)); if (query) Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v));
return fetch(url.toString(), { return fetch(url.toString(), {
headers: { headers: {
'User-Agent': 'Assembly Query',
'Client-Id': 'Commodore', 'Client-Id': 'Commodore',
}, },
}); });
@ -29,6 +29,17 @@ async function leetJson<T>(path: string, query?: Record<string, string>): Promis
return data as T; return data as T;
} }
// CommoServe may wrap arrays in an object — unwrap the first array value found.
function extractArray<T>(data: unknown): T[] {
if (Array.isArray(data)) return data as T[];
if (data && typeof data === 'object') {
for (const v of Object.values(data as object)) {
if (Array.isArray(v) && v.length > 0) return v as T[];
}
}
return [];
}
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
interface ContentItem { interface ContentItem {
@ -55,13 +66,6 @@ interface ContentEntry {
date: number; date: number;
} }
interface CategoryMapping {
id: number;
name?: string;
title?: string;
[k: string]: unknown;
}
interface PresetValue { interface PresetValue {
aqlKey: string; aqlKey: string;
name?: string; name?: string;
@ -166,9 +170,6 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null); const [searchError, setSearchError] = useState<string | null>(null);
const [corsBlocked, setCorsBlocked] = useState(false); const [corsBlocked, setCorsBlocked] = useState(false);
const [categoryFilter, setCategoryFilter] = useState<number | null>(() => _store.categoryFilter);
const [categories, setCategories] = useState<CategoryMapping[]>([]);
const [presets, setPresets] = useState<PresetGroup[]>([]); const [presets, setPresets] = useState<PresetGroup[]>([]);
const [activePreset, setActivePreset] = useState<PresetGroup | null>(null); const [activePreset, setActivePreset] = useState<PresetGroup | null>(null);
@ -191,12 +192,11 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
_store.offset = offset; _store.offset = offset;
_store.hasMore = hasMore; _store.hasMore = hasMore;
_store.hasSearched = hasSearched; _store.hasSearched = hasSearched;
_store.categoryFilter = categoryFilter;
_store.showFilter = showFilter; _store.showFilter = showFilter;
_store.filterText = filterText; _store.filterText = filterText;
_store.sortField = sortField; _store.sortField = sortField;
_store.sortDir = sortDir; _store.sortDir = sortDir;
}, [query, results, offset, hasMore, hasSearched, categoryFilter, showFilter, filterText, sortField, sortDir]); }, [query, results, offset, hasMore, hasSearched, showFilter, filterText, sortField, sortDir]);
useEffect(() => { useEffect(() => {
if (_store.scrollTop > 0 && scrollRef.current) if (_store.scrollTop > 0 && scrollRef.current)
@ -204,30 +204,22 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
}, []); }, []);
useEffect(() => { useEffect(() => {
const isCors = (e: any) => /failed to fetch|networkerror/i.test(e?.message ?? ''); leetJson<unknown>('/search/aql/presets').then(d => setPresets(extractArray<PresetGroup>(d))).catch(() => {});
leetJson<CategoryMapping[]>('/search/categories').then(setCategories).catch(e => { if (isCors(e)) setCorsBlocked(true); });
leetJson<PresetGroup[]>('/search/aql/presets').then(setPresets).catch(e => { if (isCors(e)) setCorsBlocked(true); });
}, []); }, []);
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 ────────────────────────────────────────────────────────────────── // ── Search ──────────────────────────────────────────────────────────────────
const doSearch = async (q: string, cat: number | null, fromOffset: number, append = false) => { const doSearch = async (q: string, fromOffset: number, append = false) => {
if (!append) setIsSearching(true); if (!append) setIsSearching(true);
else setIsLoadingMore(true); else setIsLoadingMore(true);
setSearchError(null); setSearchError(null);
try { try {
let aql = q.trim(); const aql = q.trim();
if (cat !== null) aql = aql ? `${aql} category:${cat}` : `category:${cat}`; const raw = await leetJson<unknown>(
const data = await leetJson<ContentItem[]>(
`/search/aql/${fromOffset}/${PAGE_SIZE}`, `/search/aql/${fromOffset}/${PAGE_SIZE}`,
aql ? { query: aql } : undefined, aql ? { query: aql } : undefined,
); );
const data = extractArray<ContentItem>(raw);
if (append) setResults(prev => [...prev, ...data]); if (append) setResults(prev => [...prev, ...data]);
else setResults(data); else setResults(data);
setOffset(fromOffset + data.length); setOffset(fromOffset + data.length);
@ -242,8 +234,8 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
} }
}; };
const handleSearch = () => { setFilterText(''); doSearch(query, categoryFilter, 0); }; const handleSearch = () => { setFilterText(''); doSearch(query, 0); };
const handleLoadMore = () => doSearch(query, categoryFilter, offset, true); const handleLoadMore = () => doSearch(query, offset, true);
const applyPreset = (group: PresetGroup, value: PresetValue) => { const applyPreset = (group: PresetGroup, value: PresetValue) => {
const prefix = PRESET_PREFIX[group.type]; const prefix = PRESET_PREFIX[group.type];
@ -253,7 +245,7 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
const trimmed = query.trim(); const trimmed = query.trim();
const next = trimmed ? `${trimmed} ${token}` : token; const next = trimmed ? `${trimmed} ${token}` : token;
setQuery(next); setQuery(next);
doSearch(next, categoryFilter, 0); doSearch(next, 0);
}; };
// ── Filter / sort ──────────────────────────────────────────────────────────── // ── Filter / sort ────────────────────────────────────────────────────────────
@ -309,8 +301,7 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
const downloadToSd = async (item: ContentItem, entry: ContentEntry) => { const downloadToSd = async (item: ContentItem, entry: ContentEntry) => {
setDownloading(entry.id); setDownloading(entry.id);
try { try {
const url = downloadUrl(item, entry); const res = await fetch(downloadUrl(item, entry));
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.arrayBuffer(); const data = await res.arrayBuffer();
const dest = joinPath(DOWNLOAD_DIR, entryFilename(entry)); const dest = joinPath(DOWNLOAD_DIR, entryFilename(entry));
@ -350,6 +341,7 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
return s; return s;
}, [config]); }, [config]);
// ── Render ──────────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────────
return ( return (
@ -432,28 +424,6 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
</div> </div>
)} )}
{categories.length > 0 && (
<div className="flex gap-1.5 overflow-x-auto pb-0.5" style={{ scrollbarWidth: '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> </div>
{/* Filter + sort bar */} {/* Filter + sort bar */}
@ -528,9 +498,7 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
{isSearching && <Loader2 className="w-3 h-3 animate-spin" />} {isSearching && <Loader2 className="w-3 h-3 animate-spin" />}
{visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}{hasMore ? '+' : ''} {visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}{hasMore ? '+' : ''}
</p> </p>
{visibleResults.map(item => { {visibleResults.map(item => (
const catLabel = categoryName[item.category] ?? `Cat ${item.category}`;
return (
<div <div
key={item.id} key={item.id}
className="flex items-stretch border-b border-neutral-100 border-l-2 border-l-transparent hover:bg-blue-50 hover:border-l-blue-400 transition-colors" className="flex items-stretch border-b border-neutral-100 border-l-2 border-l-transparent hover:bg-blue-50 hover:border-l-blue-400 transition-colors"
@ -542,7 +510,6 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <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-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" />} {item.place === 1 && <Trophy className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />}
</div> </div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap"> <div className="flex items-center gap-2 mt-0.5 flex-wrap">
@ -580,8 +547,7 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
<MoreVertical className="w-4 h-4" /> <MoreVertical className="w-4 h-4" />
</button> </button>
</div> </div>
); ))}
})}
{hasMore && ( {hasMore && (
<div className="p-4 flex justify-center"> <div className="p-4 flex justify-center">
@ -681,6 +647,7 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
key={entry.id} 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'}`} className={`px-4 py-3 rounded-lg border flex items-center gap-3 ${isMounted ? 'border-blue-300 bg-blue-50' : 'border-neutral-200'}`}
> >
<EntryIcon entry={{ name: fname, type: 'file', path: entry.path, size: entry.size, lastModified: new Date(entry.date * 1000), contentType: '' }} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium text-neutral-800 truncate">{fname}</div> <div className="text-sm font-medium text-neutral-800 truncate">{fname}</div>
<div className="text-xs text-neutral-400">{humanFileSize(entry.size)}</div> <div className="text-xs text-neutral-400">{humanFileSize(entry.size)}</div>