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 EntryLink from "../components/EntryLink";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; 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 = { type Item = {
id: number; id: number;
@@ -96,6 +99,7 @@ export default function EntriesExplorer({
const rest = machines.filter((m) => !seen.has(m.id)); const rest = machines.filter((m) => !seen.has(m.id));
return [...preferred, ...rest]; return [...preferred, ...rest];
}, [machines]); }, [machines]);
const machineOptions = useMemo(() => orderedMachines.map((m) => ({ id: m.id, label: m.name })), [orderedMachines]);
const pageSize = 20; const pageSize = 20;
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
@@ -258,146 +262,118 @@ export default function EntriesExplorer({
]} ]}
/> />
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3"> <ExplorerLayout
<div> title="Entries"
<h1 className="mb-1">Entries</h1> subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."}
<div className="text-secondary"> chips={activeFilters}
{data ? `${data.total.toLocaleString()} results` : "Loading results..."} onClearChips={resetFilters}
</div> sidebar={(
</div> <FilterSidebar>
{activeFilters.length > 0 && ( <form className="d-flex flex-column gap-2" onSubmit={onSubmit}>
<div className="d-flex flex-wrap gap-2 align-items-center"> <div>
{activeFilters.map((chip) => ( <label className="form-label small text-secondary">Search</label>
<span key={chip} className="badge text-bg-light">{chip}</span> <input
))} type="text"
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={resetFilters}> className="form-control"
Clear filters placeholder="Search titles..."
</button> value={q}
</div> onChange={(e) => setQ(e.target.value)}
)} />
</div> </div>
<div className="d-grid">
<div className="row g-3"> <button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
<div className="col-lg-3"> </div>
<div className="card shadow-sm"> <div>
<div className="card-body"> <label className="form-label small text-secondary">Genre</label>
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}> <select className="form-select" value={genreId} onChange={(e) => { setGenreId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
<option value="">All genres</option>
{genres.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div>
<label className="form-label small text-secondary">Language</label>
<select className="form-select" value={languageId} onChange={(e) => { setLanguageId(e.target.value); setPage(1); }}>
<option value="">All languages</option>
{languages.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
<div>
<label className="form-label small text-secondary">Machine</label>
<MultiSelectChips
options={machineOptions}
selected={machinetypeIds}
onToggle={(id) => {
setMachinetypeIds((current) => {
const next = new Set(current);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
const order = machineOptions.map((item) => item.id);
return order.filter((value) => next.has(value));
});
setPage(1);
}}
/>
<div className="form-text">Preferred: {preferredMachineNames.join(", ")}</div>
</div>
<div>
<label className="form-label small text-secondary">Sort</label>
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as "title" | "id_desc"); setPage(1); }}>
<option value="title">Title (AZ)</option>
<option value="id_desc">Newest</option>
</select>
</div>
<div>
<label className="form-label small text-secondary">Search scope</label>
<select className="form-select" value={scope} onChange={(e) => { setScope(e.target.value as SearchScope); setPage(1); }}>
<option value="title">Titles</option>
<option value="title_aliases">Titles + Aliases</option>
<option value="title_aliases_origins">Titles + Aliases + Origins</option>
</select>
</div>
{facets && (
<div> <div>
<label className="form-label small text-secondary">Search</label> <div className="text-secondary small mb-1">Facets</div>
<input
type="text"
className="form-control"
placeholder="Search titles..."
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div className="d-grid">
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
</div>
<div>
<label className="form-label small text-secondary">Genre</label>
<select className="form-select" value={genreId} onChange={(e) => { setGenreId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
<option value="">All genres</option>
{genres.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div>
<label className="form-label small text-secondary">Language</label>
<select className="form-select" value={languageId} onChange={(e) => { setLanguageId(e.target.value); setPage(1); }}>
<option value="">All languages</option>
{languages.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
<div>
<label className="form-label small text-secondary">Machine</label>
<div className="d-flex flex-wrap gap-2"> <div className="d-flex flex-wrap gap-2">
{orderedMachines.map((m) => { <button
const active = machinetypeIds.includes(m.id); type="button"
return ( className={`btn btn-sm ${scope === "title_aliases" ? "btn-primary" : "btn-outline-secondary"}`}
<button onClick={() => { setScope("title_aliases"); setPage(1); }}
key={m.id} disabled={facets.flags.hasAliases === 0}
type="button" title="Show results that match aliases"
className={`btn btn-sm ${active ? "btn-primary" : "btn-outline-secondary"}`} >
onClick={() => { Has aliases ({facets.flags.hasAliases})
setMachinetypeIds((current) => { </button>
const next = new Set(current); <button
if (next.has(m.id)) { type="button"
next.delete(m.id); className={`btn btn-sm ${scope === "title_aliases_origins" ? "btn-primary" : "btn-outline-secondary"}`}
} else { onClick={() => { setScope("title_aliases_origins"); setPage(1); }}
next.add(m.id); disabled={facets.flags.hasOrigins === 0}
} title="Show results that match origins"
const order = orderedMachines.map((item) => item.id); >
return order.filter((id) => next.has(id)); Has origins ({facets.flags.hasOrigins})
}); </button>
setPage(1);
}}
>
{m.name}
</button>
);
})}
</div> </div>
<div className="form-text">Default: {preferredMachineNames.join(", ")}</div>
</div> </div>
<div> )}
<label className="form-label small text-secondary">Sort</label> {loading && <div className="text-secondary small">Loading...</div>}
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as "title" | "id_desc"); setPage(1); }}> </form>
<option value="title">Title (AZ)</option> </FilterSidebar>
<option value="id_desc">Newest</option> )}
</select> >
</div> {data && data.items.length === 0 && !loading && (
<div> <div className="alert alert-warning">No results.</div>
<label className="form-label small text-secondary">Search scope</label> )}
<select className="form-select" value={scope} onChange={(e) => { setScope(e.target.value as SearchScope); setPage(1); }}> {data && data.items.length > 0 && (
<option value="title">Titles</option> <div className="table-responsive">
<option value="title_aliases">Titles + Aliases</option> <table className="table table-striped table-hover align-middle">
<option value="title_aliases_origins">Titles + Aliases + Origins</option> <thead>
</select>
</div>
{facets && (
<div>
<div className="text-secondary small mb-1">Facets</div>
<div className="d-flex flex-wrap gap-2">
<button
type="button"
className={`btn btn-sm ${scope === "title_aliases" ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => { setScope("title_aliases"); setPage(1); }}
disabled={facets.flags.hasAliases === 0}
title="Show results that match aliases"
>
Has aliases ({facets.flags.hasAliases})
</button>
<button
type="button"
className={`btn btn-sm ${scope === "title_aliases_origins" ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => { setScope("title_aliases_origins"); setPage(1); }}
disabled={facets.flags.hasOrigins === 0}
title="Show results that match origins"
>
Has origins ({facets.flags.hasOrigins})
</button>
</div>
</div>
)}
{loading && <div className="text-secondary small">Loading...</div>}
</form>
</div>
</div>
</div>
<div className="col-lg-9">
{data && data.items.length === 0 && !loading && (
<div className="alert alert-warning">No results.</div>
)}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr> <tr>
<th style={{ width: 80 }}>ID</th> <th style={{ width: 80 }}>ID</th>
<th>Title</th> <th>Title</th>
@@ -426,13 +402,13 @@ export default function EntriesExplorer({
{it.machinetypeId != null ? ( {it.machinetypeId != null ? (
it.machinetypeName ? ( it.machinetypeName ? (
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link> <Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
) : (
<span>{it.machinetypeId}</span>
)
) : ( ) : (
<span className="text-secondary">-</span> <span>{it.machinetypeId}</span>
)} )
</td> ) : (
<span className="text-secondary">-</span>
)}
</td>
<td> <td>
{it.languageId ? ( {it.languageId ? (
it.languageName ? ( it.languageName ? (
@@ -448,10 +424,9 @@ export default function EntriesExplorer({
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
)} )}
</div> </ExplorerLayout>
</div>
<div className="d-flex align-items-center gap-2 mt-4"> <div className="d-flex align-items-center gap-2 mt-4">
<span>Page {data?.page ?? 1} / {totalPages}</span> <span>Page {data?.page ?? 1} / {totalPages}</span>

View File

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