diff --git a/AGENTS.md b/AGENTS.md index dd9e566..7d2b7fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,6 +156,8 @@ Comment what the code does, not what the agent has done. The documentation's pur - validation and review: - When changes are visual or UX-related, provide concrete links/routes to validate. - Call out what to inspect visually (e.g., section names, table columns, empty states). + - Use the local `.env` for any environment-dependent behavior. + - Provide fully clickable links when sharing validation URLs. ### References diff --git a/src/app/zxdb/entries/EntriesExplorer.tsx b/src/app/zxdb/entries/EntriesExplorer.tsx index 5255002..a093e4a 100644 --- a/src/app/zxdb/entries/EntriesExplorer.tsx +++ b/src/app/zxdb/entries/EntriesExplorer.tsx @@ -25,17 +25,26 @@ type Paged = { 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; 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(initialUrlState?.scope ?? "title"); + const [facets, setFacets] = useState(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 = 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({ )} + {facets && ( +
+
+ Facets + + +
+
+ )} +
{data && data.items.length === 0 && !loading && (
No results.
diff --git a/src/app/zxdb/entries/page.tsx b/src/app/zxdb/entries/page.tsx index 9b546c8..c722754 100644 --- a/src/app/zxdb/entries/page.tsx +++ b/src/app/zxdb/entries/page.tsx @@ -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 }} /> ); diff --git a/src/server/repo/zxdb.ts b/src/server/repo/zxdb.ts index 7ec2782..78a2642 100644 --- a/src/server/repo/zxdb.ts +++ b/src/server/repo/zxdb.ts @@ -97,6 +97,10 @@ export interface EntryFacets { genres: FacetItem[]; languages: FacetItem[]; machinetypes: FacetItem[]; + flags: { + hasAliases: number; + hasOrigins: number; + }; } function buildEntrySearchUnion(pattern: string, scope: EntrySearchScope) { @@ -1582,12 +1586,12 @@ export async function getEntryFacets(params: SearchParams): Promise 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 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 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, + }, }; }