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 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 (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>
|
||||||
<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 (A–Z)</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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