Use react-bootstrap throughout, improve entry detail

Switch sidebar components (FilterSection, FilterSidebar, Pagination)
and both explorer pages to use react-bootstrap: Card, Table, Badge,
Button, Alert, Form.Control, Form.Select, InputGroup, Collapse,
Spinner. Use react-bootstrap-icons for Search, ChevronDown, Download,
BoxArrowUpRight, etc.

Entry detail page: remove MD5 columns from Downloads and Files tables.
Hide empty sections entirely instead of showing placeholder cards.
Human-readable file sizes (KB/MB). Web links shown as compact list
with external-link icons. Notes rendered as badge+text instead of
table rows. Scores and web links moved to sidebar.

No-results alert now shows active machine filter names and offers to
search all machines via Alert.Link.

Update CLAUDE.md with react-bootstrap design conventions and remove
stale ZxdbExplorer.tsx references.

claude-opus-4-6@McFiver
This commit is contained in:
2026-02-17 18:17:58 +00:00
parent fe1dfa4170
commit 65de62deaf
14 changed files with 1453 additions and 1595 deletions

View File

@@ -0,0 +1,86 @@
"use client";
import { useCallback, useRef, useState } from "react";
type Paged<T> = {
items: T[];
page: number;
pageSize: number;
total: number;
};
/**
* Manages API search fetching with automatic request cancellation
* to prevent race conditions from rapid filter/page changes.
* Keeps previous results visible while a new request is in flight.
*
* @param onExtra - optional callback to capture extra fields from the response
* (e.g., facets) that sit alongside the standard paged fields.
*/
export default function useSearchFetch<T>(
endpoint: string,
initialData: Paged<T> | null = null,
onExtra?: (json: Record<string, unknown>) => void,
) {
const [data, setData] = useState<Paged<T> | null>(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetchIdRef = useRef(0);
const fetch_ = useCallback(
async (params: URLSearchParams) => {
// Cancel any in-flight request
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const id = ++fetchIdRef.current;
setLoading(true);
setError(null);
try {
const res = await globalThis.fetch(
`${endpoint}?${params.toString()}`,
{ signal: controller.signal },
);
if (!res.ok) throw new Error(`Search failed (${res.status})`);
const json = await res.json();
// Only apply if this is still the latest request
if (id === fetchIdRef.current) {
setData({
items: json.items,
page: json.page,
pageSize: json.pageSize,
total: json.total,
});
onExtra?.(json);
}
} catch (e: unknown) {
if (e instanceof DOMException && e.name === "AbortError") return;
if (id === fetchIdRef.current) {
const msg = e instanceof Error ? e.message : "Search failed";
console.error(msg);
setError(msg);
setData({ items: [] as T[], page: 1, pageSize: 20, total: 0 });
}
} finally {
if (id === fetchIdRef.current) {
setLoading(false);
}
}
},
[endpoint, onExtra],
);
// Allow syncing SSR data without a fetch
const syncData = useCallback((d: Paged<T>) => {
abortRef.current?.abort();
setData(d);
setLoading(false);
setError(null);
}, []);
return { data, loading, error, fetch: fetch_, syncData };
}