Share explorer sidebar components

Introduce reusable explorer layout, sidebar, chips, and multi-select components.

Signed-off-by: codex@lucy.xalior.com
This commit is contained in:
2026-01-11 13:21:19 +00:00
parent 8a9c5395bd
commit 79d161afe1
6 changed files with 262 additions and 195 deletions

View File

@@ -5,6 +5,9 @@ import Link from "next/link";
import EntryLink from "../components/EntryLink";
import { usePathname, useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import ExplorerLayout from "@/components/explorer/ExplorerLayout";
import FilterSidebar from "@/components/explorer/FilterSidebar";
import MultiSelectChips from "@/components/explorer/MultiSelectChips";
type Item = {
id: number;
@@ -96,6 +99,7 @@ export default function EntriesExplorer({
const rest = machines.filter((m) => !seen.has(m.id));
return [...preferred, ...rest];
}, [machines]);
const machineOptions = useMemo(() => orderedMachines.map((m) => ({ id: m.id, label: m.name })), [orderedMachines]);
const pageSize = 20;
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
@@ -258,29 +262,13 @@ export default function EntriesExplorer({
]}
/>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h1 className="mb-1">Entries</h1>
<div className="text-secondary">
{data ? `${data.total.toLocaleString()} results` : "Loading results..."}
</div>
</div>
{activeFilters.length > 0 && (
<div className="d-flex flex-wrap gap-2 align-items-center">
{activeFilters.map((chip) => (
<span key={chip} className="badge text-bg-light">{chip}</span>
))}
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={resetFilters}>
Clear filters
</button>
</div>
)}
</div>
<div className="row g-3">
<div className="col-lg-3">
<div className="card shadow-sm">
<div className="card-body">
<ExplorerLayout
title="Entries"
subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."}
chips={activeFilters}
onClearChips={resetFilters}
sidebar={(
<FilterSidebar>
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}>
<div>
<label className="form-label small text-secondary">Search</label>
@@ -315,34 +303,24 @@ export default function EntriesExplorer({
</div>
<div>
<label className="form-label small text-secondary">Machine</label>
<div className="d-flex flex-wrap gap-2">
{orderedMachines.map((m) => {
const active = machinetypeIds.includes(m.id);
return (
<button
key={m.id}
type="button"
className={`btn btn-sm ${active ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => {
<MultiSelectChips
options={machineOptions}
selected={machinetypeIds}
onToggle={(id) => {
setMachinetypeIds((current) => {
const next = new Set(current);
if (next.has(m.id)) {
next.delete(m.id);
if (next.has(id)) {
next.delete(id);
} else {
next.add(m.id);
next.add(id);
}
const order = orderedMachines.map((item) => item.id);
return order.filter((id) => next.has(id));
const order = machineOptions.map((item) => item.id);
return order.filter((value) => next.has(value));
});
setPage(1);
}}
>
{m.name}
</button>
);
})}
</div>
<div className="form-text">Default: {preferredMachineNames.join(", ")}</div>
/>
<div className="form-text">Preferred: {preferredMachineNames.join(", ")}</div>
</div>
<div>
<label className="form-label small text-secondary">Sort</label>
@@ -386,11 +364,9 @@ export default function EntriesExplorer({
)}
{loading && <div className="text-secondary small">Loading...</div>}
</form>
</div>
</div>
</div>
<div className="col-lg-9">
</FilterSidebar>
)}
>
{data && data.items.length === 0 && !loading && (
<div className="alert alert-warning">No results.</div>
)}
@@ -450,8 +426,7 @@ export default function EntriesExplorer({
</table>
</div>
)}
</div>
</div>
</ExplorerLayout>
<div className="d-flex align-items-center gap-2 mt-4">
<span>Page {data?.page ?? 1} / {totalPages}</span>

View File

@@ -5,6 +5,9 @@ import Link from "next/link";
import EntryLink from "../components/EntryLink";
import { usePathname, useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import ExplorerLayout from "@/components/explorer/ExplorerLayout";
import FilterSidebar from "@/components/explorer/FilterSidebar";
import MultiSelectChips from "@/components/explorer/MultiSelectChips";
type Item = {
entryId: number;
@@ -98,6 +101,7 @@ export default function ReleasesExplorer({
const rest = machines.filter((m) => !seen.has(m.id));
return [...preferred, ...rest];
}, [machines]);
const machineOptions = useMemo(() => orderedMachines.map((m) => ({ id: m.id, label: m.name })), [orderedMachines]);
const pageSize = 20;
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
@@ -258,19 +262,11 @@ export default function ReleasesExplorer({
]}
/>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h1 className="mb-1">Releases</h1>
<div className="text-secondary">
{data ? `${data.total.toLocaleString()} results` : "Loading results..."}
</div>
</div>
</div>
<div className="row g-3">
<div className="col-lg-3">
<div className="card shadow-sm">
<div className="card-body">
<ExplorerLayout
title="Releases"
subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."}
sidebar={(
<FilterSidebar>
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}>
<div>
<label className="form-label small text-secondary">Search title</label>
@@ -306,34 +302,24 @@ export default function ReleasesExplorer({
</div>
<div>
<label className="form-label small text-secondary">DL Machine</label>
<div className="d-flex flex-wrap gap-2">
{orderedMachines.map((m) => {
const active = dMachinetypeIds.includes(m.id);
return (
<button
key={m.id}
type="button"
className={`btn btn-sm ${active ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => {
<MultiSelectChips
options={machineOptions}
selected={dMachinetypeIds}
onToggle={(id) => {
setDMachinetypeIds((current) => {
const next = new Set(current);
if (next.has(m.id)) {
next.delete(m.id);
if (next.has(id)) {
next.delete(id);
} else {
next.add(m.id);
next.add(id);
}
const order = orderedMachines.map((item) => item.id);
return order.filter((id) => next.has(id));
const order = machineOptions.map((item) => item.id);
return order.filter((value) => next.has(value));
});
setPage(1);
}}
>
{m.name}
</button>
);
})}
</div>
<div className="form-text">Default: {preferredMachineNames.join(", ")}</div>
/>
<div className="form-text">Preferred: {preferredMachineNames.join(", ")}</div>
</div>
<div>
<label className="form-label small text-secondary">File type</label>
@@ -386,11 +372,9 @@ export default function ReleasesExplorer({
</div>
{loading && <div className="text-secondary small">Loading...</div>}
</form>
</div>
</div>
</div>
<div className="col-lg-9">
</FilterSidebar>
)}
>
{data && data.items.length === 0 && !loading && (
<div className="alert alert-warning">No results.</div>
)}
@@ -438,8 +422,7 @@ export default function ReleasesExplorer({
</table>
</div>
)}
</div>
</div>
</ExplorerLayout>
<div className="d-flex align-items-center gap-2 mt-4">
<span>Page {data?.page ?? 1} / {totalPages}</span>

View File

@@ -0,0 +1,39 @@
import { ReactNode } from "react";
import FilterChips from "./FilterChips";
type ExplorerLayoutProps = {
title: string;
subtitle?: string;
chips?: string[];
onClearChips?: () => void;
sidebar: ReactNode;
children: ReactNode;
};
export default function ExplorerLayout({
title,
subtitle,
chips = [],
onClearChips,
sidebar,
children,
}: ExplorerLayoutProps) {
return (
<div>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h1 className="mb-1">{title}</h1>
{subtitle ? <div className="text-secondary">{subtitle}</div> : null}
</div>
{chips.length > 0 ? (
<FilterChips chips={chips} onClear={onClearChips} />
) : null}
</div>
<div className="row g-3">
<div className="col-lg-3">{sidebar}</div>
<div className="col-lg-9">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
type FilterChipsProps = {
chips: string[];
onClear?: () => void;
clearLabel?: string;
};
export default function FilterChips({ chips, onClear, clearLabel = "Clear filters" }: FilterChipsProps) {
return (
<div className="d-flex flex-wrap gap-2 align-items-center">
{chips.map((chip) => (
<span key={chip} className="badge text-bg-light">{chip}</span>
))}
{onClear ? (
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={onClear}>
{clearLabel}
</button>
) : null}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { ReactNode } from "react";
type FilterSidebarProps = {
children: ReactNode;
};
export default function FilterSidebar({ children }: FilterSidebarProps) {
return (
<div className="card shadow-sm">
<div className="card-body">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
type ChipOption<T extends number | string> = {
id: T;
label: string;
};
type MultiSelectChipsProps<T extends number | string> = {
options: ChipOption<T>[];
selected: T[];
onToggle: (id: T) => void;
size?: "sm" | "md";
};
export default function MultiSelectChips<T extends number | string>({
options,
selected,
onToggle,
size = "sm",
}: MultiSelectChipsProps<T>) {
const btnSize = size === "sm" ? "btn-sm" : "";
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>
);
}