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:
@@ -156,6 +156,8 @@ Comment what the code does, not what the agent has done. The documentation's pur
|
|||||||
- validation and review:
|
- validation and review:
|
||||||
- When changes are visual or UX-related, provide concrete links/routes to validate.
|
- 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).
|
- 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
|
### References
|
||||||
|
|
||||||
|
|||||||
@@ -25,17 +25,26 @@ type Paged<T> = {
|
|||||||
total: number;
|
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({
|
export default function EntriesExplorer({
|
||||||
initial,
|
initial,
|
||||||
initialGenres,
|
initialGenres,
|
||||||
initialLanguages,
|
initialLanguages,
|
||||||
initialMachines,
|
initialMachines,
|
||||||
|
initialFacets,
|
||||||
initialUrlState,
|
initialUrlState,
|
||||||
}: {
|
}: {
|
||||||
initial?: Paged<Item>;
|
initial?: Paged<Item>;
|
||||||
initialGenres?: { id: number; name: string }[];
|
initialGenres?: { id: number; name: string }[];
|
||||||
initialLanguages?: { id: string; name: string }[];
|
initialLanguages?: { id: string; name: string }[];
|
||||||
initialMachines?: { id: number; name: string }[];
|
initialMachines?: { id: number; name: string }[];
|
||||||
|
initialFacets?: EntryFacets | null;
|
||||||
initialUrlState?: {
|
initialUrlState?: {
|
||||||
q: string;
|
q: string;
|
||||||
page: number;
|
page: number;
|
||||||
@@ -65,6 +74,7 @@ export default function EntriesExplorer({
|
|||||||
);
|
);
|
||||||
const [sort, setSort] = useState<"title" | "id_desc">(initialUrlState?.sort ?? "id_desc");
|
const [sort, setSort] = useState<"title" | "id_desc">(initialUrlState?.sort ?? "id_desc");
|
||||||
const [scope, setScope] = useState<SearchScope>(initialUrlState?.scope ?? "title");
|
const [scope, setScope] = useState<SearchScope>(initialUrlState?.scope ?? "title");
|
||||||
|
const [facets, setFacets] = useState<EntryFacets | null>(initialFacets ?? null);
|
||||||
|
|
||||||
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]);
|
||||||
@@ -82,7 +92,7 @@ export default function EntriesExplorer({
|
|||||||
router.replace(qs ? `${pathname}?${qs}` : pathname);
|
router.replace(qs ? `${pathname}?${qs}` : pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchData(query: string, p: number) {
|
async function fetchData(query: string, p: number, withFacets: boolean) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -94,10 +104,14 @@ export default function EntriesExplorer({
|
|||||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||||
if (sort) params.set("sort", sort);
|
if (sort) params.set("sort", sort);
|
||||||
if (scope !== "title") params.set("scope", scope);
|
if (scope !== "title") params.set("scope", scope);
|
||||||
|
if (withFacets) params.set("facets", "true");
|
||||||
const res = await fetch(`/api/zxdb/search?${params.toString()}`);
|
const res = await fetch(`/api/zxdb/search?${params.toString()}`);
|
||||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||||
const json: Paged<Item> = await res.json();
|
const json = await res.json();
|
||||||
setData(json);
|
setData(json);
|
||||||
|
if (withFacets && json.facets) {
|
||||||
|
setFacets(json.facets as EntryFacets);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setData({ items: [], page: 1, pageSize, total: 0 });
|
setData({ items: [], page: 1, pageSize, total: 0 });
|
||||||
@@ -133,7 +147,7 @@ export default function EntriesExplorer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateUrl(page);
|
updateUrl(page);
|
||||||
fetchData(q, page);
|
fetchData(q, page, true);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [page, genreId, languageId, machinetypeId, sort, scope]);
|
}, [page, genreId, languageId, machinetypeId, sort, scope]);
|
||||||
|
|
||||||
@@ -159,7 +173,7 @@ export default function EntriesExplorer({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPage(1);
|
setPage(1);
|
||||||
updateUrl(1);
|
updateUrl(1);
|
||||||
fetchData(q, 1);
|
fetchData(q, 1, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevHref = useMemo(() => {
|
const prevHref = useMemo(() => {
|
||||||
@@ -251,6 +265,32 @@ export default function EntriesExplorer({
|
|||||||
)}
|
)}
|
||||||
</form>
|
</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">
|
<div className="mt-3">
|
||||||
{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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import EntriesExplorer from "./EntriesExplorer";
|
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 = {
|
export const metadata = {
|
||||||
title: "ZXDB Entries",
|
title: "ZXDB Entries",
|
||||||
@@ -20,7 +20,7 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
|
|||||||
| "title_aliases"
|
| "title_aliases"
|
||||||
| "title_aliases_origins";
|
| "title_aliases_origins";
|
||||||
|
|
||||||
const [initial, genres, langs, machines] = await Promise.all([
|
const [initial, genres, langs, machines, facets] = await Promise.all([
|
||||||
searchEntries({
|
searchEntries({
|
||||||
page,
|
page,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
@@ -34,6 +34,14 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
|
|||||||
listGenres(),
|
listGenres(),
|
||||||
listLanguages(),
|
listLanguages(),
|
||||||
listMachinetypes(),
|
listMachinetypes(),
|
||||||
|
getEntryFacets({
|
||||||
|
q,
|
||||||
|
sort,
|
||||||
|
scope,
|
||||||
|
genreId: genreId ? Number(genreId) : undefined,
|
||||||
|
languageId: languageId || undefined,
|
||||||
|
machinetypeId: machinetypeId ? Number(machinetypeId) : undefined,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,6 +50,7 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
|
|||||||
initialGenres={genres}
|
initialGenres={genres}
|
||||||
initialLanguages={langs}
|
initialLanguages={langs}
|
||||||
initialMachines={machines}
|
initialMachines={machines}
|
||||||
|
initialFacets={facets}
|
||||||
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort, scope }}
|
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort, scope }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ export interface EntryFacets {
|
|||||||
genres: FacetItem<number>[];
|
genres: FacetItem<number>[];
|
||||||
languages: FacetItem<string>[];
|
languages: FacetItem<string>[];
|
||||||
machinetypes: FacetItem<number>[];
|
machinetypes: FacetItem<number>[];
|
||||||
|
flags: {
|
||||||
|
hasAliases: number;
|
||||||
|
hasOrigins: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEntrySearchUnion(pattern: string, scope: EntrySearchScope) {
|
function buildEntrySearchUnion(pattern: string, scope: EntrySearchScope) {
|
||||||
@@ -1582,12 +1586,12 @@ export async function getEntryFacets(params: SearchParams): Promise<EntryFacets>
|
|||||||
if (scope !== "title") {
|
if (scope !== "title") {
|
||||||
try {
|
try {
|
||||||
const union = buildEntrySearchUnion(pattern, scope);
|
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 {
|
} 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 {
|
} 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}`);
|
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
|
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 };
|
type FacetRow = { id: number | string | null; name: string | null; count: number | string };
|
||||||
return {
|
return {
|
||||||
genres: (genresRows as unknown as FacetRow[])
|
genres: (genresRows as unknown as FacetRow[])
|
||||||
@@ -1637,6 +1657,10 @@ export async function getEntryFacets(params: SearchParams): Promise<EntryFacets>
|
|||||||
machinetypes: (mtRows as unknown as FacetRow[])
|
machinetypes: (mtRows as unknown as FacetRow[])
|
||||||
.map((r) => ({ id: Number(r.id), name: r.name ?? "(none)", count: Number(r.count) }))
|
.map((r) => ({ id: Number(r.id), name: r.name ?? "(none)", count: Number(r.count) }))
|
||||||
.filter((r) => !!r.id),
|
.filter((r) => !!r.id),
|
||||||
|
flags: {
|
||||||
|
hasAliases,
|
||||||
|
hasOrigins,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user