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:
2026-01-10 19:21:46 +00:00
parent 964b48abf1
commit 5130a72641
4 changed files with 84 additions and 9 deletions

View File

@@ -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>

View File

@@ -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 }}
/>
);

View File

@@ -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,
},
};
}