Add entry facets and links
Surface alias/origin facets, SSR facets on entries page, fix facet query ambiguity, and document clickable links. Signed-off-by: codex@lucy.xalior.com
This commit is contained in:
@@ -25,17 +25,26 @@ type Paged<T> = {
|
||||
total: number;
|
||||
};
|
||||
|
||||
type EntryFacets = {
|
||||
genres: { id: number; name: string; count: number }[];
|
||||
languages: { id: string; name: string; count: number }[];
|
||||
machinetypes: { id: number; name: string; count: number }[];
|
||||
flags: { hasAliases: number; hasOrigins: number };
|
||||
};
|
||||
|
||||
export default function EntriesExplorer({
|
||||
initial,
|
||||
initialGenres,
|
||||
initialLanguages,
|
||||
initialMachines,
|
||||
initialFacets,
|
||||
initialUrlState,
|
||||
}: {
|
||||
initial?: Paged<Item>;
|
||||
initialGenres?: { id: number; name: string }[];
|
||||
initialLanguages?: { id: string; name: string }[];
|
||||
initialMachines?: { id: number; name: string }[];
|
||||
initialFacets?: EntryFacets | null;
|
||||
initialUrlState?: {
|
||||
q: string;
|
||||
page: number;
|
||||
@@ -65,6 +74,7 @@ export default function EntriesExplorer({
|
||||
);
|
||||
const [sort, setSort] = useState<"title" | "id_desc">(initialUrlState?.sort ?? "id_desc");
|
||||
const [scope, setScope] = useState<SearchScope>(initialUrlState?.scope ?? "title");
|
||||
const [facets, setFacets] = useState<EntryFacets | null>(initialFacets ?? null);
|
||||
|
||||
const pageSize = 20;
|
||||
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||
@@ -82,7 +92,7 @@ export default function EntriesExplorer({
|
||||
router.replace(qs ? `${pathname}?${qs}` : pathname);
|
||||
}
|
||||
|
||||
async function fetchData(query: string, p: number) {
|
||||
async function fetchData(query: string, p: number, withFacets: boolean) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
@@ -94,10 +104,14 @@ export default function EntriesExplorer({
|
||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||
if (sort) params.set("sort", sort);
|
||||
if (scope !== "title") params.set("scope", scope);
|
||||
if (withFacets) params.set("facets", "true");
|
||||
const res = await fetch(`/api/zxdb/search?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
const json: Paged<Item> = await res.json();
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
if (withFacets && json.facets) {
|
||||
setFacets(json.facets as EntryFacets);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setData({ items: [], page: 1, pageSize, total: 0 });
|
||||
@@ -133,7 +147,7 @@ export default function EntriesExplorer({
|
||||
return;
|
||||
}
|
||||
updateUrl(page);
|
||||
fetchData(q, page);
|
||||
fetchData(q, page, true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, genreId, languageId, machinetypeId, sort, scope]);
|
||||
|
||||
@@ -159,7 +173,7 @@ export default function EntriesExplorer({
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
updateUrl(1);
|
||||
fetchData(q, 1);
|
||||
fetchData(q, 1, true);
|
||||
}
|
||||
|
||||
const prevHref = useMemo(() => {
|
||||
@@ -251,6 +265,32 @@ export default function EntriesExplorer({
|
||||
)}
|
||||
</form>
|
||||
|
||||
{facets && (
|
||||
<div className="mt-3">
|
||||
<div className="d-flex flex-wrap gap-2 align-items-center">
|
||||
<span className="text-secondary small">Facets</span>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="mt-3">
|
||||
{data && data.items.length === 0 && !loading && (
|
||||
<div className="alert alert-warning">No results.</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import EntriesExplorer from "./EntriesExplorer";
|
||||
import { listGenres, listLanguages, listMachinetypes, searchEntries } from "@/server/repo/zxdb";
|
||||
import { getEntryFacets, listGenres, listLanguages, listMachinetypes, searchEntries } from "@/server/repo/zxdb";
|
||||
|
||||
export const metadata = {
|
||||
title: "ZXDB Entries",
|
||||
@@ -20,7 +20,7 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
|
||||
| "title_aliases"
|
||||
| "title_aliases_origins";
|
||||
|
||||
const [initial, genres, langs, machines] = await Promise.all([
|
||||
const [initial, genres, langs, machines, facets] = await Promise.all([
|
||||
searchEntries({
|
||||
page,
|
||||
pageSize: 20,
|
||||
@@ -34,6 +34,14 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
|
||||
listGenres(),
|
||||
listLanguages(),
|
||||
listMachinetypes(),
|
||||
getEntryFacets({
|
||||
q,
|
||||
sort,
|
||||
scope,
|
||||
genreId: genreId ? Number(genreId) : undefined,
|
||||
languageId: languageId || undefined,
|
||||
machinetypeId: machinetypeId ? Number(machinetypeId) : undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -42,6 +50,7 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
|
||||
initialGenres={genres}
|
||||
initialLanguages={langs}
|
||||
initialMachines={machines}
|
||||
initialFacets={facets}
|
||||
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort, scope }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -97,6 +97,10 @@ export interface EntryFacets {
|
||||
genres: FacetItem<number>[];
|
||||
languages: FacetItem<string>[];
|
||||
machinetypes: FacetItem<number>[];
|
||||
flags: {
|
||||
hasAliases: number;
|
||||
hasOrigins: number;
|
||||
};
|
||||
}
|
||||
|
||||
function buildEntrySearchUnion(pattern: string, scope: EntrySearchScope) {
|
||||
@@ -1582,12 +1586,12 @@ export async function getEntryFacets(params: SearchParams): Promise<EntryFacets>
|
||||
if (scope !== "title") {
|
||||
try {
|
||||
const union = buildEntrySearchUnion(pattern, scope);
|
||||
whereParts.push(sql`id in (select entry_id from (${union}) as matches)`);
|
||||
whereParts.push(sql`e.id in (select entry_id from (${union}) as matches)`);
|
||||
} catch {
|
||||
whereParts.push(sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`);
|
||||
whereParts.push(sql`e.id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`);
|
||||
}
|
||||
} else {
|
||||
whereParts.push(sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`);
|
||||
whereParts.push(sql`e.id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`);
|
||||
}
|
||||
}
|
||||
if (params.genreId) whereParts.push(sql`${entries.genretypeId} = ${params.genreId}`);
|
||||
@@ -1626,6 +1630,22 @@ export async function getEntryFacets(params: SearchParams): Promise<EntryFacets>
|
||||
order by count desc, name asc
|
||||
`);
|
||||
|
||||
let hasAliases = 0;
|
||||
let hasOrigins = 0;
|
||||
try {
|
||||
const rows = await db.execute(sql`
|
||||
select
|
||||
sum(e.id in (select ${searchByAliases.entryId} from ${searchByAliases})) as hasAliases,
|
||||
sum(e.id in (select ${searchByOrigins.entryId} from ${searchByOrigins})) as hasOrigins
|
||||
from ${entries} as e
|
||||
${whereSql}
|
||||
`);
|
||||
type FlagRow = { hasAliases: number | string | null; hasOrigins: number | string | null };
|
||||
const row = (rows as unknown as FlagRow[])[0];
|
||||
hasAliases = Number(row?.hasAliases ?? 0);
|
||||
hasOrigins = Number(row?.hasOrigins ?? 0);
|
||||
} catch {}
|
||||
|
||||
type FacetRow = { id: number | string | null; name: string | null; count: number | string };
|
||||
return {
|
||||
genres: (genresRows as unknown as FacetRow[])
|
||||
@@ -1637,6 +1657,10 @@ export async function getEntryFacets(params: SearchParams): Promise<EntryFacets>
|
||||
machinetypes: (mtRows as unknown as FacetRow[])
|
||||
.map((r) => ({ id: Number(r.id), name: r.name ?? "(none)", count: Number(r.count) }))
|
||||
.filter((r) => !!r.id),
|
||||
flags: {
|
||||
hasAliases,
|
||||
hasOrigins,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user