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:
@@ -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,146 +262,118 @@ 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">
|
||||
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}>
|
||||
<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>
|
||||
<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>
|
||||
<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 (A–Z)</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>
|
||||
<label className="form-label small text-secondary">Search</label>
|
||||
<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="text-secondary small mb-1">Facets</div>
|
||||
<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={() => {
|
||||
setMachinetypeIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(m.id)) {
|
||||
next.delete(m.id);
|
||||
} else {
|
||||
next.add(m.id);
|
||||
}
|
||||
const order = orderedMachines.map((item) => item.id);
|
||||
return order.filter((id) => next.has(id));
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<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 className="form-text">Default: {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 (A–Z)</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 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>
|
||||
)}
|
||||
{loading && <div className="text-secondary small">Loading...</div>}
|
||||
</form>
|
||||
</FilterSidebar>
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
<th style={{ width: 80 }}>ID</th>
|
||||
<th>Title</th>
|
||||
@@ -426,13 +402,13 @@ export default function EntriesExplorer({
|
||||
{it.machinetypeId != null ? (
|
||||
it.machinetypeName ? (
|
||||
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
|
||||
) : (
|
||||
<span>{it.machinetypeId}</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-secondary">-</span>
|
||||
)}
|
||||
</td>
|
||||
<span>{it.machinetypeId}</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-secondary">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{it.languageId ? (
|
||||
it.languageName ? (
|
||||
@@ -448,10 +424,9 @@ export default function EntriesExplorer({
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ExplorerLayout>
|
||||
|
||||
<div className="d-flex align-items-center gap-2 mt-4">
|
||||
<span>Page {data?.page ?? 1} / {totalPages}</span>
|
||||
|
||||
@@ -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,20 +262,12 @@ 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">
|
||||
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}>
|
||||
<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>
|
||||
<input
|
||||
@@ -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={() => {
|
||||
setDMachinetypeIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(m.id)) {
|
||||
next.delete(m.id);
|
||||
} else {
|
||||
next.add(m.id);
|
||||
}
|
||||
const order = orderedMachines.map((item) => item.id);
|
||||
return order.filter((id) => next.has(id));
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="form-text">Default: {preferredMachineNames.join(", ")}</div>
|
||||
<MultiSelectChips
|
||||
options={machineOptions}
|
||||
selected={dMachinetypeIds}
|
||||
onToggle={(id) => {
|
||||
setDMachinetypeIds((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">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>
|
||||
|
||||
39
src/components/explorer/ExplorerLayout.tsx
Normal file
39
src/components/explorer/ExplorerLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/components/explorer/FilterChips.tsx
Normal file
20
src/components/explorer/FilterChips.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/components/explorer/FilterSidebar.tsx
Normal file
13
src/components/explorer/FilterSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/explorer/MultiSelectChips.tsx
Normal file
37
src/components/explorer/MultiSelectChips.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user