feat(SearchCommoServe): refactor search logic and enhance array extraction functionality
This commit is contained in:
parent
e3f7ba9c19
commit
936dc5db12
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user