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,54 @@
"use client";
import { ReactNode, useState } from "react";
import { Collapse } from "react-bootstrap";
import { ChevronDown } from "react-bootstrap-icons";
type FilterSectionProps = {
label: string;
badge?: string;
defaultOpen?: boolean;
children: ReactNode;
};
export default function FilterSection({
label,
badge,
defaultOpen = true,
children,
}: FilterSectionProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border-bottom pb-2">
<button
type="button"
className="btn btn-sm w-100 d-flex align-items-center justify-content-between p-0 text-start"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span className="form-label small text-secondary mb-0 fw-semibold">{label}</span>
<span className="d-flex align-items-center gap-1">
{!open && badge && (
<span className="badge text-bg-primary rounded-pill" style={{ fontSize: "0.7rem" }}>
{badge}
</span>
)}
<ChevronDown
size={10}
className="text-secondary"
style={{
transition: "transform 0.2s",
transform: open ? "rotate(180deg)" : "rotate(0deg)",
}}
/>
</span>
</button>
<Collapse in={open}>
<div>
<div className="mt-1">{children}</div>
</div>
</Collapse>
</div>
);
}

View File

@@ -1,13 +1,31 @@
import { ReactNode } from "react";
import { Card, Button } from "react-bootstrap";
type FilterSidebarProps = {
children: ReactNode;
onReset?: () => void;
loading?: boolean;
};
export default function FilterSidebar({ children }: FilterSidebarProps) {
export default function FilterSidebar({ children, onReset, loading }: FilterSidebarProps) {
return (
<div className="card shadow-sm">
<div className="card-body">{children}</div>
</div>
<Card className="shadow-sm">
<Card.Body className="d-flex flex-column gap-2">
{children}
{onReset && (
<div className="border-top pt-2 mt-1">
<Button
variant="outline-secondary"
size="sm"
className="w-100"
onClick={onReset}
disabled={loading}
>
Reset all filters
</Button>
</div>
)}
</Card.Body>
</Card>
);
}

View File

@@ -1,3 +1,7 @@
"use client";
import { useState } from "react";
type ChipOption<T extends number | string> = {
id: T;
label: string;
@@ -8,6 +12,10 @@ type MultiSelectChipsProps<T extends number | string> = {
selected: T[];
onToggle: (id: T) => void;
size?: "sm" | "md";
/** When set, chips start collapsed showing just selected count + names */
collapsible?: boolean;
/** Max selected labels to show in collapsed summary before truncating */
collapsedMax?: number;
};
export default function MultiSelectChips<T extends number | string>({
@@ -15,23 +23,60 @@ export default function MultiSelectChips<T extends number | string>({
selected,
onToggle,
size = "sm",
collapsible = false,
collapsedMax = 3,
}: MultiSelectChipsProps<T>) {
const [expanded, setExpanded] = useState(!collapsible);
const btnSize = size === "sm" ? "btn-sm" : "";
if (!expanded) {
const selectedLabels = selected
.map((id) => options.find((o) => o.id === id)?.label)
.filter(Boolean) as string[];
const shown = selectedLabels.slice(0, collapsedMax);
const extra = selectedLabels.length - shown.length;
const summary = shown.length
? shown.join(", ") + (extra > 0 ? ` +${extra}` : "")
: "None";
return (
<button
type="button"
className="btn btn-sm btn-outline-secondary w-100 text-start d-flex justify-content-between align-items-center"
onClick={() => setExpanded(true)}
>
<span className="text-truncate">{summary}</span>
<span className="badge text-bg-primary rounded-pill ms-2">{selected.length}</span>
</button>
);
}
return (
<div className="d-flex flex-wrap gap-2">
{options.map((option) => {
const active = selected.includes(option.id);
return (
<button
key={String(option.id)}
type="button"
className={`btn ${btnSize} ${active ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => onToggle(option.id)}
>
{option.label}
</button>
);
})}
<div>
<div className="d-flex flex-wrap gap-1">
{options.map((option) => {
const active = selected.includes(option.id);
return (
<button
key={String(option.id)}
type="button"
className={`btn ${btnSize} ${active ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => onToggle(option.id)}
>
{option.label}
</button>
);
})}
</div>
{collapsible && (
<button
type="button"
className="btn btn-link btn-sm p-0 mt-1 text-secondary"
onClick={() => setExpanded(false)}
>
Collapse
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import { useMemo } from "react";
import { Button, Spinner } from "react-bootstrap";
import { ChevronLeft, ChevronRight } from "react-bootstrap-icons";
type PaginationProps = {
page: number;
totalPages: number;
loading?: boolean;
/** Build href for a given page number (for SSR/link fallback) */
buildHref: (p: number) => string;
onPageChange: (p: number) => void;
};
export default function Pagination({
page,
totalPages,
loading,
buildHref,
onPageChange,
}: PaginationProps) {
const canPrev = page > 1;
const canNext = page < totalPages;
const prevHref = useMemo(() => buildHref(Math.max(1, page - 1)), [buildHref, page]);
const nextHref = useMemo(() => buildHref(Math.min(totalPages, page + 1)), [buildHref, page, totalPages]);
return (
<div className="d-flex align-items-center gap-2 mt-4">
<span className={loading ? "text-secondary" : ""}>
Page {page} / {totalPages}
</span>
{loading && (
<Spinner animation="border" size="sm" variant="secondary" role="status" />
)}
<div className="ms-auto d-flex gap-2">
<Button
as="a"
variant="outline-secondary"
href={prevHref}
disabled={!canPrev}
onClick={(e: React.MouseEvent) => {
if (!canPrev) return;
e.preventDefault();
onPageChange(page - 1);
}}
>
<ChevronLeft size={14} /> Prev
</Button>
<Button
as="a"
variant="outline-secondary"
href={nextHref}
disabled={!canNext}
onClick={(e: React.MouseEvent) => {
if (!canNext) return;
e.preventDefault();
onPageChange(page + 1);
}}
>
Next <ChevronRight size={14} />
</Button>
</div>
</div>
);
}