Implement magazine reviews, label details, and year filtering

- Aggregate magazine references from all releases on the Entry detail page.
- Display country names and external links (Wikipedia/Website) on the Label detail page.
- Add a year filter to the ZXDB Explorer to search entries by release year.

Signed-off: junie@lucy.xalior.com
This commit is contained in:
2026-02-17 12:03:36 +00:00
parent 9807005305
commit 53eb9a1501
5 changed files with 274 additions and 7 deletions

View File

@@ -13,6 +13,7 @@ const querySchema = z.object({
.length(2, "languageId must be a 2-char code") .length(2, "languageId must be a 2-char code")
.optional(), .optional(),
machinetypeId: z.string().optional(), machinetypeId: z.string().optional(),
year: z.coerce.number().int().optional(),
sort: z.enum(["title", "id_desc"]).optional(), sort: z.enum(["title", "id_desc"]).optional(),
scope: z.enum(["title", "title_aliases", "title_aliases_origins"]).optional(), scope: z.enum(["title", "title_aliases", "title_aliases_origins"]).optional(),
facets: z.coerce.boolean().optional(), facets: z.coerce.boolean().optional(),
@@ -36,6 +37,7 @@ export async function GET(req: NextRequest) {
genreId: searchParams.get("genreId") ?? undefined, genreId: searchParams.get("genreId") ?? undefined,
languageId: searchParams.get("languageId") ?? undefined, languageId: searchParams.get("languageId") ?? undefined,
machinetypeId: searchParams.get("machinetypeId") ?? undefined, machinetypeId: searchParams.get("machinetypeId") ?? undefined,
year: searchParams.get("year") ?? undefined,
sort: searchParams.get("sort") ?? undefined, sort: searchParams.get("sort") ?? undefined,
scope: searchParams.get("scope") ?? undefined, scope: searchParams.get("scope") ?? undefined,
facets: searchParams.get("facets") ?? undefined, facets: searchParams.get("facets") ?? undefined,

View File

@@ -41,6 +41,7 @@ export default function ZxdbExplorer({
const [genreId, setGenreId] = useState<number | "">(""); const [genreId, setGenreId] = useState<number | "">("");
const [languageId, setLanguageId] = useState<string | "">(""); const [languageId, setLanguageId] = useState<string | "">("");
const [machinetypeId, setMachinetypeId] = useState<number | "">(""); const [machinetypeId, setMachinetypeId] = useState<number | "">("");
const [year, setYear] = useState<string>("");
const [sort, setSort] = useState<"title" | "id_desc">("id_desc"); const [sort, setSort] = useState<"title" | "id_desc">("id_desc");
const pageSize = 20; const pageSize = 20;
@@ -56,6 +57,7 @@ export default function ZxdbExplorer({
if (genreId !== "") params.set("genreId", String(genreId)); if (genreId !== "") params.set("genreId", String(genreId));
if (languageId !== "") params.set("languageId", String(languageId)); if (languageId !== "") params.set("languageId", String(languageId));
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId)); if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
if (year !== "") params.set("year", year);
if (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
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}`);
@@ -89,13 +91,14 @@ export default function ZxdbExplorer({
genreId === "" && genreId === "" &&
languageId === "" && languageId === "" &&
machinetypeId === "" && machinetypeId === "" &&
year === "" &&
sort === "id_desc" sort === "id_desc"
) { ) {
return; return;
} }
fetchData(q, page); fetchData(q, page);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, genreId, languageId, machinetypeId, sort]); }, [page, genreId, languageId, machinetypeId, year, sort]);
// Load filter lists on mount only if not provided by server // Load filter lists on mount only if not provided by server
useEffect(() => { useEffect(() => {
@@ -161,6 +164,16 @@ export default function ZxdbExplorer({
))} ))}
</select> </select>
</div> </div>
<div className="col-auto">
<input
type="number"
className="form-control"
style={{ width: 100 }}
placeholder="Year"
value={year}
onChange={(e) => setYear(e.target.value)}
/>
</div>
<div className="col-auto"> <div className="col-auto">
<select className="form-select" value={sort} onChange={(e) => setSort(e.target.value as "title" | "id_desc")}> <select className="form-select" value={sort} onChange={(e) => setSort(e.target.value as "title" | "id_desc")}>
<option value="title">Sort: Title</option> <option value="title">Sort: Title</option>

View File

@@ -130,6 +130,26 @@ export type EntryDetailData = {
// Additional relationships // Additional relationships
aliases?: { releaseSeq: number; languageId: string; title: string }[]; aliases?: { releaseSeq: number; languageId: string; title: string }[];
webrefs?: { link: string; languageId: string; website: { id: number; name: string; link?: string | null } }[]; webrefs?: { link: string; languageId: string; website: { id: number; name: string; link?: string | null } }[];
magazineRefs?: {
id: number;
issueId: number;
magazineId: number | null;
magazineName: string | null;
referencetypeId: number;
referencetypeName: string | null;
page: number;
isOriginal: number;
scoreGroup: string;
issue: {
dateYear: number | null;
dateMonth: number | null;
dateDay: number | null;
volume: number | null;
number: number | null;
special: string | null;
supplement: string | null;
};
}[];
}; };
export default function EntryDetailClient({ data }: { data: EntryDetailData | null }) { export default function EntryDetailClient({ data }: { data: EntryDetailData | null }) {
@@ -293,7 +313,52 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
</div> </div>
</div> </div>
<div className="card shadow-sm"> <div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Magazine References</h5>
{(!data.magazineRefs || data.magazineRefs.length === 0) && <div className="text-secondary">No magazine references recorded</div>}
{data.magazineRefs && data.magazineRefs.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Magazine</th>
<th style={{ width: 140 }}>Issue</th>
<th style={{ width: 140 }}>Type</th>
<th style={{ width: 120 }}>Page</th>
<th>Score</th>
</tr>
</thead>
<tbody>
{data.magazineRefs.map((m) => (
<tr key={m.id}>
<td>
{m.magazineId ? (
<Link href={`/zxdb/magazines/${m.magazineId}`}>{m.magazineName}</Link>
) : (
<span>{m.magazineName}</span>
)}
</td>
<td>
<Link href={`/zxdb/issues/${m.issueId}`}>
{m.issue.dateYear ? `${m.issue.dateYear} ` : ""}
{m.issue.number ? `#${m.issue.number}` : ""}
{m.issue.special ? ` (${m.issue.special})` : ""}
</Link>
</td>
<td>{m.referencetypeName}</td>
<td>{m.page > 0 ? m.page : "-"}</td>
<td>{m.scoreGroup || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body d-flex flex-wrap gap-2"> <div className="card-body d-flex flex-wrap gap-2">
<Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</Link> <Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</Link>
<Link className="btn btn-sm btn-outline-primary" href="/zxdb">Back to Explorer</Link> <Link className="btn btn-sm btn-outline-primary" href="/zxdb">Back to Explorer</Link>

View File

@@ -10,6 +10,12 @@ type Label = {
name: string; name: string;
labeltypeId: string | null; labeltypeId: string | null;
labeltypeName: string | null; labeltypeName: string | null;
countryId: string | null;
countryName: string | null;
country2Id: string | null;
country2Name: string | null;
linkWikipedia: string | null;
linkSite: string | null;
permissions: { permissions: {
website: { id: number; name: string; link?: string | null }; website: { id: number; name: string; link?: string | null };
type: { id: string; name: string | null }; type: { id: string; name: string | null };
@@ -57,6 +63,24 @@ export default function LabelDetailClient({ id, initial, initialTab, initialQ }:
</span> </span>
</div> </div>
</div> </div>
{(initial.label.countryId || initial.label.linkWikipedia || initial.label.linkSite) && (
<div className="mt-2 d-flex gap-3 flex-wrap align-items-center">
{initial.label.countryId && (
<span className="text-secondary small">
Country: <strong>{initial.label.countryName || initial.label.countryId}</strong>
{initial.label.country2Id && (
<> / <strong>{initial.label.country2Name || initial.label.country2Id}</strong></>
)}
</span>
)}
{initial.label.linkWikipedia && (
<a href={initial.label.linkWikipedia} target="_blank" rel="noreferrer" className="btn btn-sm btn-outline-secondary py-0">Wikipedia</a>
)}
{initial.label.linkSite && (
<a href={initial.label.linkSite} target="_blank" rel="noreferrer" className="btn btn-sm btn-outline-secondary py-0">Website</a>
)}
</div>
)}
<div className="row g-4 mt-1"> <div className="row g-4 mt-1">
<div className="col-lg-6"> <div className="col-lg-6">

View File

@@ -52,6 +52,7 @@ import {
magrefs, magrefs,
searchByMagrefs, searchByMagrefs,
referencetypes, referencetypes,
countries,
} from "@/server/schema/zxdb"; } from "@/server/schema/zxdb";
export type EntrySearchScope = "title" | "title_aliases" | "title_aliases_origins"; export type EntrySearchScope = "title" | "title_aliases" | "title_aliases_origins";
@@ -64,6 +65,8 @@ export interface SearchParams {
genreId?: number; genreId?: number;
languageId?: string; languageId?: string;
machinetypeId?: number | number[]; machinetypeId?: number | number[];
// Year filter
year?: number;
// Sorting // Sorting
sort?: "title" | "id_desc"; sort?: "title" | "id_desc";
// Search scope (defaults to titles only) // Search scope (defaults to titles only)
@@ -206,6 +209,9 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
const ids = params.machinetypeId.map((id) => sql`${id}`); const ids = params.machinetypeId.map((id) => sql`${id}`);
whereClauses.push(sql`${entries.machinetypeId} in (${sql.join(ids, sql`, `)})`); whereClauses.push(sql`${entries.machinetypeId} in (${sql.join(ids, sql`, `)})`);
} }
if (typeof params.year === "number") {
whereClauses.push(sql`${entries.id} in (select entry_id from releases where release_year = ${params.year})`);
}
const whereExpr = whereClauses.length ? and(...whereClauses) : undefined; const whereExpr = whereClauses.length ? and(...whereClauses) : undefined;
@@ -250,9 +256,30 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
if (scope !== "title") { if (scope !== "title") {
try { try {
const union = buildEntrySearchUnion(pattern, scope); const union = buildEntrySearchUnion(pattern, scope);
const whereClauses: Array<ReturnType<typeof sql>> = [
sql`${entries.id} in (select entry_id from (${union}) as matches)`
];
if (typeof params.genreId === "number") {
whereClauses.push(eq(entries.genretypeId, params.genreId));
}
if (typeof params.languageId === "string") {
whereClauses.push(eq(entries.languageId, params.languageId));
}
if (typeof params.machinetypeId === "number") {
whereClauses.push(eq(entries.machinetypeId, params.machinetypeId));
} else if (Array.isArray(params.machinetypeId) && params.machinetypeId.length > 0) {
const ids = params.machinetypeId.map((id) => sql`${id}`);
whereClauses.push(sql`${entries.machinetypeId} in (${sql.join(ids, sql`, `)})`);
}
if (typeof params.year === "number") {
whereClauses.push(sql`${entries.id} in (select entry_id from releases where release_year = ${params.year})`);
}
const whereExpr = and(...whereClauses);
const countRows = await db.execute(sql` const countRows = await db.execute(sql`
select count(distinct entry_id) as total select count(distinct id) as total
from (${union}) as matches from entries
where ${whereExpr}
`); `);
type CountRow = { total: number | string }; type CountRow = { total: number | string };
const total = Number((countRows as unknown as CountRow[])[0]?.total ?? 0); const total = Number((countRows as unknown as CountRow[])[0]?.total ?? 0);
@@ -273,7 +300,7 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
.leftJoin(genretypes, eq(genretypes.id, entries.genretypeId)) .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId)) .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId)) .leftJoin(languages, eq(languages.id, entries.languageId))
.where(sql`${entries.id} in (select entry_id from (${union}) as matches)`) .where(whereExpr)
.groupBy(entries.id) .groupBy(entries.id)
.orderBy( .orderBy(
...(preferMachineOrder ? [preferMachineOrder] : []), ...(preferMachineOrder ? [preferMachineOrder] : []),
@@ -289,10 +316,31 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
} }
// Count matches via helper table // Count matches via helper table
const whereClauses: Array<ReturnType<typeof sql>> = [
sql`lower(${searchByTitles.entryTitle}) like ${pattern}`
];
if (typeof params.genreId === "number") {
whereClauses.push(eq(entries.genretypeId, params.genreId));
}
if (typeof params.languageId === "string") {
whereClauses.push(eq(entries.languageId, params.languageId));
}
if (typeof params.machinetypeId === "number") {
whereClauses.push(eq(entries.machinetypeId, params.machinetypeId));
} else if (Array.isArray(params.machinetypeId) && params.machinetypeId.length > 0) {
const ids = params.machinetypeId.map((id) => sql`${id}`);
whereClauses.push(sql`${entries.machinetypeId} in (${sql.join(ids, sql`, `)})`);
}
if (typeof params.year === "number") {
whereClauses.push(sql`${entries.id} in (select entry_id from releases where release_year = ${params.year})`);
}
const whereExpr = and(...whereClauses);
const countRows = await db const countRows = await db
.select({ total: sql<number>`count(distinct ${searchByTitles.entryId})` }) .select({ total: sql<number>`count(distinct ${searchByTitles.entryId})` })
.from(searchByTitles) .from(searchByTitles)
.where(sql`lower(${searchByTitles.entryTitle}) like ${pattern}`); .innerJoin(entries, eq(entries.id, searchByTitles.entryId))
.where(whereExpr);
const total = Number(countRows[0]?.total ?? 0); const total = Number(countRows[0]?.total ?? 0);
@@ -314,7 +362,7 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
.leftJoin(genretypes, eq(genretypes.id, entries.genretypeId)) .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId)) .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId)) .leftJoin(languages, eq(languages.id, entries.languageId))
.where(sql`lower(${searchByTitles.entryTitle}) like ${pattern}`) .where(whereExpr)
.groupBy(entries.id) .groupBy(entries.id)
.orderBy( .orderBy(
...(preferMachineOrder ? [preferMachineOrder] : []), ...(preferMachineOrder ? [preferMachineOrder] : []),
@@ -458,6 +506,26 @@ export interface EntryDetail {
// Additional relationships surfaced on the entry detail page // Additional relationships surfaced on the entry detail page
aliases?: { releaseSeq: number; languageId: string; title: string }[]; aliases?: { releaseSeq: number; languageId: string; title: string }[];
webrefs?: { link: string; languageId: string; website: { id: number; name: string; link?: string | null } }[]; webrefs?: { link: string; languageId: string; website: { id: number; name: string; link?: string | null } }[];
magazineRefs?: {
id: number;
issueId: number;
magazineId: number | null;
magazineName: string | null;
referencetypeId: number;
referencetypeName: string | null;
page: number;
isOriginal: number;
scoreGroup: string;
issue: {
dateYear: number | null;
dateMonth: number | null;
dateDay: number | null;
volume: number | null;
number: number | null;
special: string | null;
supplement: string | null;
};
}[];
} }
export async function getEntryById(id: number): Promise<EntryDetail | null> { export async function getEntryById(id: number): Promise<EntryDetail | null> {
@@ -731,6 +799,24 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
notetypeName: string | null; notetypeName: string | null;
text: string; text: string;
}[] = []; }[] = [];
let magazineRefRows: {
id: number;
issueId: number;
magazineId: number | null;
magazineName: string | null;
referencetypeId: number;
referencetypeName: string | null;
page: number;
isOriginal: number;
scoreGroup: string;
issueDateYear: number | null;
issueDateMonth: number | null;
issueDateDay: number | null;
issueVolume: number | null;
issueNumber: number | null;
issueSpecial: string | null;
issueSupplement: string | null;
}[] = [];
try { try {
aliasRows = await db aliasRows = await db
.select({ releaseSeq: aliases.releaseSeq, languageId: aliases.languageId, title: aliases.title }) .select({ releaseSeq: aliases.releaseSeq, languageId: aliases.languageId, title: aliases.title })
@@ -907,6 +993,43 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
noteRows = rows as typeof noteRows; noteRows = rows as typeof noteRows;
} catch {} } catch {}
try {
const rows = await db
.select({
id: magrefs.id,
issueId: magrefs.issueId,
magazineId: magazines.id,
magazineName: magazines.name,
referencetypeId: magrefs.referencetypeId,
referencetypeName: referencetypes.name,
page: magrefs.page,
isOriginal: magrefs.isOriginal,
scoreGroup: magrefs.scoreGroup,
issueDateYear: issues.dateYear,
issueDateMonth: issues.dateMonth,
issueDateDay: issues.dateDay,
issueVolume: issues.volume,
issueNumber: issues.number,
issueSpecial: issues.special,
issueSupplement: issues.supplement,
})
.from(searchByMagrefs)
.innerJoin(magrefs, eq(magrefs.id, searchByMagrefs.magrefId))
.leftJoin(issues, eq(issues.id, magrefs.issueId))
.leftJoin(magazines, eq(magazines.id, issues.magazineId))
.leftJoin(referencetypes, eq(referencetypes.id, magrefs.referencetypeId))
.where(eq(searchByMagrefs.entryId, id))
.orderBy(
asc(magazines.name),
asc(issues.dateYear),
asc(issues.dateMonth),
asc(issues.id),
asc(magrefs.page),
asc(magrefs.id)
);
magazineRefRows = rows as typeof magazineRefRows;
} catch {}
return { return {
id: base.id, id: base.id,
title: base.title, title: base.title,
@@ -1028,6 +1151,26 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
})), })),
aliases: aliasRows.map((a) => ({ releaseSeq: Number(a.releaseSeq), languageId: a.languageId, title: a.title })), aliases: aliasRows.map((a) => ({ releaseSeq: Number(a.releaseSeq), languageId: a.languageId, title: a.title })),
webrefs: webrefRows.map((w) => ({ link: w.link, languageId: w.languageId, website: { id: Number(w.websiteId), name: w.websiteName, link: w.websiteLink } })), webrefs: webrefRows.map((w) => ({ link: w.link, languageId: w.languageId, website: { id: Number(w.websiteId), name: w.websiteName, link: w.websiteLink } })),
magazineRefs: magazineRefRows.map((m) => ({
id: m.id,
issueId: Number(m.issueId),
magazineId: m.magazineId != null ? Number(m.magazineId) : null,
magazineName: m.magazineName ?? null,
referencetypeId: Number(m.referencetypeId),
referencetypeName: m.referencetypeName ?? null,
page: Number(m.page),
isOriginal: Number(m.isOriginal),
scoreGroup: m.scoreGroup ?? "",
issue: {
dateYear: m.issueDateYear != null ? Number(m.issueDateYear) : null,
dateMonth: m.issueDateMonth != null ? Number(m.issueDateMonth) : null,
dateDay: m.issueDateDay != null ? Number(m.issueDateDay) : null,
volume: m.issueVolume != null ? Number(m.issueVolume) : null,
number: m.issueNumber != null ? Number(m.issueNumber) : null,
special: m.issueSpecial ?? null,
supplement: m.issueSupplement ?? null,
},
})),
}; };
} }
@@ -1035,6 +1178,12 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
export interface LabelDetail extends LabelSummary { export interface LabelDetail extends LabelSummary {
labeltypeName: string | null; labeltypeName: string | null;
countryId: string | null;
countryName: string | null;
country2Id: string | null;
country2Name: string | null;
linkWikipedia: string | null;
linkSite: string | null;
permissions: { permissions: {
website: { id: number; name: string; link?: string | null }; website: { id: number; name: string; link?: string | null };
type: { id: string; name: string | null }; type: { id: string; name: string | null };
@@ -1099,9 +1248,17 @@ export async function getLabelById(id: number): Promise<LabelDetail | null> {
name: labels.name, name: labels.name,
labeltypeId: labels.labeltypeId, labeltypeId: labels.labeltypeId,
labeltypeName: labeltypes.name, labeltypeName: labeltypes.name,
countryId: labels.countryId,
countryName: sql<string>`c1.text`,
country2Id: labels.country2Id,
country2Name: sql<string>`c2.text`,
linkWikipedia: labels.linkWikipedia,
linkSite: labels.linkSite,
}) })
.from(labels) .from(labels)
.leftJoin(labeltypes, eq(labeltypes.id, labels.labeltypeId)) .leftJoin(labeltypes, eq(labeltypes.id, labels.labeltypeId))
.leftJoin(sql`${countries} c1`, eq(sql`c1.id`, labels.countryId))
.leftJoin(sql`${countries} c2`, eq(sql`c2.id`, labels.country2Id))
.where(eq(labels.id, id)) .where(eq(labels.id, id))
.limit(1); .limit(1);
const base = rows[0]; const base = rows[0];
@@ -1165,6 +1322,12 @@ export async function getLabelById(id: number): Promise<LabelDetail | null> {
name: base.name, name: base.name,
labeltypeId: base.labeltypeId, labeltypeId: base.labeltypeId,
labeltypeName: base.labeltypeName ?? null, labeltypeName: base.labeltypeName ?? null,
countryId: base.countryId ?? null,
countryName: base.countryName ?? null,
country2Id: base.country2Id ?? null,
country2Name: base.country2Name ?? null,
linkWikipedia: base.linkWikipedia ?? null,
linkSite: base.linkSite ?? null,
permissions: permissionRows.map((p) => ({ permissions: permissionRows.map((p) => ({
website: { id: Number(p.websiteId), name: p.websiteName, link: p.websiteLink ?? null }, website: { id: Number(p.websiteId), name: p.websiteName, link: p.websiteLink ?? null },
type: { id: p.permissiontypeId, name: p.permissiontypeName ?? null }, type: { id: p.permissiontypeId, name: p.permissiontypeName ?? null },