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:
86
src/hooks/useSearchFetch.ts
Normal file
86
src/hooks/useSearchFetch.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user