Adding the first stubs of the magazine browser

This commit is contained in:
2025-12-18 13:10:58 +00:00
parent 279abac91a
commit a1a04a89cf
11 changed files with 632 additions and 228 deletions

View File

@@ -0,0 +1,79 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getIssue } from "@/server/repo/zxdb";
import EntryLink from "@/app/zxdb/components/EntryLink";
export const metadata = { title: "ZXDB Issue" };
export const revalidate = 3600;
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const issueId = Number(id);
if (!Number.isFinite(issueId) || issueId <= 0) return notFound();
const issue = await getIssue(issueId);
if (!issue) return notFound();
const ym = [issue.dateYear ?? "", issue.dateMonth ? String(issue.dateMonth).padStart(2, "0") : ""].filter(Boolean).join("/");
return (
<div>
<div className="mb-3 d-flex gap-2 flex-wrap">
<Link className="btn btn-outline-secondary btn-sm" href={`/zxdb/magazines/${issue.magazine.id}`}> Back to magazine</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">All magazines</Link>
{issue.linkMask && (
<a className="btn btn-outline-secondary btn-sm" href={issue.linkMask} target="_blank" rel="noreferrer">Issue link</a>
)}
{issue.archiveMask && (
<a className="btn btn-outline-secondary btn-sm" href={issue.archiveMask} target="_blank" rel="noreferrer">Archive</a>
)}
</div>
<h1 className="mb-1">{issue.magazine.title}</h1>
<div className="text-secondary mb-3">
Issue: {ym || issue.id}{issue.volume != null ? ` · Vol ${issue.volume}` : ""}{issue.number != null ? ` · No ${issue.number}` : ""}
</div>
{(issue.special || issue.supplement) && (
<div className="mb-3">
{issue.special && <div><strong>Special:</strong> {issue.special}</div>}
{issue.supplement && <div><strong>Supplement:</strong> {issue.supplement}</div>}
</div>
)}
<h2 className="h5 mt-4">References</h2>
{issue.refs.length === 0 ? (
<div className="text-secondary">No references recorded.</div>
) : (
<div className="table-responsive">
<table className="table table-sm align-middle">
<thead>
<tr>
<th style={{ width: 80 }}>Page</th>
<th style={{ width: 140 }}>Type</th>
<th>Reference</th>
</tr>
</thead>
<tbody>
{issue.refs.map((r) => (
<tr key={r.id}>
<td>{r.page}</td>
<td>{r.typeName}</td>
<td>
{r.entryId ? (
<EntryLink id={r.entryId} title={r.entryTitle ?? undefined} />
) : r.labelId ? (
<Link href={`/zxdb/labels/${r.labelId}`}>{r.labelName ?? r.labelId}</Link>
) : (
<span className="text-secondary"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,85 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getMagazine } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Magazine" };
export const revalidate = 3600;
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const magazineId = Number(id);
if (!Number.isFinite(magazineId) || magazineId <= 0) return notFound();
const mag = await getMagazine(magazineId);
if (!mag) return notFound();
return (
<div>
<h1 className="mb-1">{mag.title}</h1>
<div className="text-secondary mb-3">Language: {mag.languageId}</div>
<div className="mb-3 d-flex gap-2 flex-wrap">
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines"> Back to list</Link>
{mag.linkSite && (
<a className="btn btn-outline-secondary btn-sm" href={mag.linkSite} target="_blank" rel="noreferrer">
Official site
</a>
)}
</div>
<h2 className="h5 mt-4">Issues</h2>
{mag.issues.length === 0 ? (
<div className="text-secondary">No issues found.</div>
) : (
<div className="table-responsive">
<table className="table table-sm align-middle">
<thead>
<tr>
<th style={{ width: 200 }}>Issue</th>
<th style={{ width: 100 }}>Volume</th>
<th style={{ width: 100 }}>Number</th>
<th>Special</th>
<th>Supplement</th>
<th style={{ width: 100 }}>Links</th>
</tr>
</thead>
<tbody>
{mag.issues.map((i) => (
<tr key={i.id}>
<td>
<Link href={`/zxdb/issues/${i.id}`} className="link-underline link-underline-opacity-0">
{i.dateYear ?? ""}
{i.dateMonth ? `/${String(i.dateMonth).padStart(2, "0")}` : ""}
{" "}
<span className="text-secondary">(open issue)</span>
</Link>
</td>
<td>{i.volume ?? ""}</td>
<td>{i.number ?? ""}</td>
<td>{i.special ?? ""}</td>
<td>{i.supplement ?? ""}</td>
<td>
<div className="d-flex gap-2">
{i.linkMask && (
<a className="btn btn-outline-secondary btn-sm" href={i.linkMask} target="_blank" rel="noreferrer" title="Link">
<span className="bi bi-link-45deg" aria-hidden />
<span className="visually-hidden">Link</span>
</a>
)}
{i.archiveMask && (
<a className="btn btn-outline-secondary btn-sm" href={i.archiveMask} target="_blank" rel="noreferrer" title="Archive">
<span className="bi bi-archive" aria-hidden />
<span className="visually-hidden">Archive</span>
</a>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,81 @@
import Link from "next/link";
import { listMagazines } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Magazines" };
// Depends on searchParams (?q=, ?page=). Force dynamic so each request renders correctly.
export const dynamic = "force-dynamic";
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const sp = await searchParams;
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const data = await listMagazines({ q, page, pageSize: 20 });
return (
<div>
<h1 className="mb-3">Magazines</h1>
<form className="mb-3" action="/zxdb/magazines" method="get">
<div className="input-group">
<input type="text" className="form-control" name="q" placeholder="Search magazines..." defaultValue={q} />
<button className="btn btn-outline-secondary" type="submit">
<span className="bi bi-search" aria-hidden />
<span className="visually-hidden">Search</span>
</button>
</div>
</form>
<div className="list-group">
{data.items.map((m) => (
<Link key={m.id} className="list-group-item list-group-item-action d-flex justify-content-between align-items-center" href={`/zxdb/magazines/${m.id}`}>
<span>
{m.title}
<span className="text-secondary ms-2">({m.languageId})</span>
</span>
<span className="badge bg-secondary rounded-pill" title="Issues">
{m.issueCount}
</span>
</Link>
))}
</div>
<Pagination page={data.page} pageSize={data.pageSize} total={data.total} q={q} />
</div>
);
}
function Pagination({ page, pageSize, total, q }: { page: number; pageSize: number; total: number; q: string }) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (totalPages <= 1) return null;
const makeHref = (p: number) => {
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", String(p));
return `/zxdb/magazines?${params.toString()}`;
};
return (
<nav className="mt-3" aria-label="Pagination">
<ul className="pagination">
<li className={`page-item ${page <= 1 ? "disabled" : ""}`}>
<Link className="page-link" href={makeHref(Math.max(1, page - 1))}>
Previous
</Link>
</li>
<li className="page-item disabled">
<span className="page-link">Page {page} of {totalPages}</span>
</li>
<li className={`page-item ${page >= totalPages ? "disabled" : ""}`}>
<Link className="page-link" href={makeHref(Math.min(totalPages, page + 1))}>
Next
</Link>
</li>
</ul>
</nav>
);
}

View File

@@ -44,6 +44,22 @@ export default async function Page() {
</div>
</Link>
</div>
<div className="col-sm-6 col-lg-4">
<Link href="/zxdb/magazines" className="text-decoration-none">
<div className="card h-100 shadow-sm">
<div className="card-body d-flex align-items-center">
<div className="me-3" aria-hidden>
<span className="bi bi-journal-text" style={{ fontSize: 28 }} />
</div>
<div>
<h5 className="card-title mb-1">Magazines</h5>
<div className="card-text text-secondary">Browse magazines and their issues</div>
</div>
</div>
</div>
</Link>
</div>
</div>
<div className="mt-4">

View File

@@ -23,6 +23,10 @@ import {
aliases,
webrefs,
websites,
magazines,
issues,
magrefs,
referencetypes,
} from "@/server/schema/zxdb";
export interface SearchParams {
@@ -66,6 +70,58 @@ export interface EntryFacets {
machinetypes: FacetItem<number>[];
}
export interface MagazineListItem {
id: number;
title: string;
languageId: string;
issueCount: number;
}
export interface MagazineDetail {
id: number;
title: string;
languageId: string;
linkSite?: string | null;
linkMask?: string | null;
archiveMask?: string | null;
issues: Array<{
id: number;
dateYear: number | null;
dateMonth: number | null;
number: number | null;
volume: number | null;
special: string | null;
supplement: string | null;
linkMask?: string | null;
archiveMask?: string | null;
}>;
}
export interface IssueDetail {
id: number;
magazine: { id: number; title: string };
dateYear: number | null;
dateMonth: number | null;
number: number | null;
volume: number | null;
special: string | null;
supplement: string | null;
linkMask?: string | null;
archiveMask?: string | null;
refs: Array<{
id: number;
page: number;
typeId: number;
typeName: string;
entryId: number | null;
entryTitle: string | null;
labelId: number | null;
labelName: string | null;
isOriginal: number;
scoreGroup: string;
}>
}
export async function searchEntries(params: SearchParams): Promise<PagedResult<SearchResultItem>> {
const q = (params.q ?? "").trim();
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
@@ -1180,3 +1236,149 @@ export async function listCurrencies() {
export async function listRoletypes() {
return db.select().from(roletypes).orderBy(roletypes.name);
}
export async function listMagazines(params: { q?: string; page?: number; pageSize?: number }): Promise<PagedResult<MagazineListItem>> {
const q = (params.q ?? "").trim();
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
const page = Math.max(1, params.page ?? 1);
const offset = (page - 1) * pageSize;
const whereExpr = q ? like(magazines.name, `%${q}%`) : sql`true`;
const [items, totalRows] = await Promise.all([
db
.select({
id: magazines.id,
// Expose as `title` to UI while DB column is `name`
title: magazines.name,
languageId: magazines.languageId,
issueCount: sql<number>`count(${issues.id})`,
})
.from(magazines)
.leftJoin(issues, eq(issues.magazineId, magazines.id))
.where(whereExpr)
.groupBy(magazines.id)
.orderBy(asc(magazines.name))
.limit(pageSize)
.offset(offset),
db
.select({ cnt: sql<number>`count(*)` })
.from(magazines)
.where(whereExpr),
]);
return {
items,
page,
pageSize,
total: totalRows[0]?.cnt ?? 0,
};
}
export async function getMagazine(id: number): Promise<MagazineDetail | null> {
const rows = await db
.select({
id: magazines.id,
// Alias DB `name` as `title` for UI shape
title: magazines.name,
languageId: magazines.languageId,
linkSite: magazines.linkSite,
linkMask: magazines.linkMask,
archiveMask: magazines.archiveMask,
})
.from(magazines)
.where(eq(magazines.id, id));
if (rows.length === 0) return null;
const mag = rows[0];
const iss = await db
.select({
id: issues.id,
dateYear: issues.dateYear,
dateMonth: issues.dateMonth,
number: issues.number,
volume: issues.volume,
special: issues.special,
supplement: issues.supplement,
linkMask: issues.linkMask,
archiveMask: issues.archiveMask,
})
.from(issues)
.where(eq(issues.magazineId, id))
.orderBy(
asc(issues.dateYear),
asc(issues.dateMonth),
asc(issues.volume),
asc(issues.number),
asc(issues.id),
);
return { ...mag, issues: iss };
}
export async function getIssue(id: number): Promise<IssueDetail | null> {
const rows = await db
.select({
id: issues.id,
magazineId: issues.magazineId,
magazineTitle: magazines.name,
dateYear: issues.dateYear,
dateMonth: issues.dateMonth,
number: issues.number,
volume: issues.volume,
special: issues.special,
supplement: issues.supplement,
linkMask: issues.linkMask,
archiveMask: issues.archiveMask,
})
.from(issues)
.leftJoin(magazines, eq(magazines.id, issues.magazineId))
.where(eq(issues.id, id));
const base = rows[0];
if (!base) return null;
const refs = await db
.select({
id: magrefs.id,
page: magrefs.page,
typeId: magrefs.referencetypeId,
typeName: referencetypes.name,
entryId: magrefs.entryId,
entryTitle: entries.title,
labelId: magrefs.labelId,
labelName: labels.name,
isOriginal: magrefs.isOriginal,
scoreGroup: magrefs.scoreGroup,
})
.from(magrefs)
.leftJoin(referencetypes, eq(referencetypes.id, magrefs.referencetypeId))
.leftJoin(entries, eq(entries.id, magrefs.entryId))
.leftJoin(labels, eq(labels.id, magrefs.labelId))
.where(eq(magrefs.issueId, id))
.orderBy(asc(magrefs.page), asc(magrefs.id));
return {
id: base.id,
magazine: { id: Number(base.magazineId), title: base.magazineTitle ?? "" },
dateYear: base.dateYear,
dateMonth: base.dateMonth,
number: base.number,
volume: base.volume,
special: base.special,
supplement: base.supplement,
linkMask: base.linkMask,
archiveMask: base.archiveMask,
refs: refs.map((r) => ({
id: r.id,
page: r.page,
typeId: Number(r.typeId),
typeName: r.typeName ?? "",
entryId: r.entryId ?? null,
entryTitle: r.entryTitle ?? null,
labelId: r.labelId ?? null,
labelName: r.labelName ?? null,
isOriginal: Number(r.isOriginal),
scoreGroup: r.scoreGroup,
})),
};
}

View File

@@ -7,9 +7,10 @@ export const entries = mysqlTable("entries", {
isXrated: tinyint("is_xrated").notNull(),
machinetypeId: tinyint("machinetype_id"),
maxPlayers: tinyint("max_players").notNull().default(1),
// DB allows NULLs on many of these
languageId: char("language_id", { length: 2 }),
genretypeSpotId: tinyint("spot_genretype_id"),
genretypeId: tinyint("genretype_id"),
genretypeSpotId: tinyint("spot_genretype_id"),
availabletypeId: char("availabletype_id", { length: 1 }),
withoutLoadScreen: tinyint("without_load_screen").notNull(),
withoutInlay: tinyint("without_inlay").notNull(),
@@ -28,6 +29,14 @@ export type Entry = typeof entries.$inferSelect;
export const labels = mysqlTable("labels", {
id: int("id").notNull().primaryKey(),
name: varchar("name", { length: 100 }).notNull(),
countryId: char("country_id", { length: 2 }),
country2Id: char("country2_id", { length: 2 }),
fromId: int("from_id"),
ownerId: int("owner_id"),
wasRenamed: tinyint("was_renamed").notNull().default(0),
deceased: varchar("deceased", { length: 200 }),
linkWikipedia: varchar("link_wikipedia", { length: 200 }),
linkSite: varchar("link_site", { length: 200 }),
labeltypeId: char("labeltype_id", { length: 1 }),
});
@@ -54,6 +63,8 @@ export const authors = mysqlTable("authors", {
entryId: int("entry_id").notNull(),
labelId: int("label_id").notNull(),
teamId: int("team_id"),
// Present in schema; sequence of the author for a given entry
authorSeq: smallint("author_seq").notNull().default(1),
});
export const publishers = mysqlTable("publishers", {
@@ -143,6 +154,35 @@ export const hosts = mysqlTable("hosts", {
magazineId: smallint("magazine_id"),
});
// ---- Magazines and Issues (subset used by the app) ----
export const magazines = mysqlTable("magazines", {
id: smallint("id").notNull().primaryKey(),
// ZXDB column is `name`
name: varchar("name", { length: 100 }).notNull(),
countryId: char("country_id", { length: 2 }).notNull(),
languageId: char("language_id", { length: 2 }).notNull(),
linkSite: varchar("link_site", { length: 200 }),
magtypeId: char("magtype_id", { length: 1 }).notNull(),
topicId: int("topic_id"),
linkMask: varchar("link_mask", { length: 250 }),
archiveMask: varchar("archive_mask", { length: 250 }),
translationMask: varchar("translation_mask", { length: 250 }),
});
export const issues = mysqlTable("issues", {
id: int("id").notNull().primaryKey(),
magazineId: smallint("magazine_id").notNull(),
dateYear: smallint("date_year"),
dateMonth: smallint("date_month"),
dateDay: smallint("date_day"),
volume: smallint("volume"),
number: smallint("number"),
special: varchar("special", { length: 100 }),
supplement: varchar("supplement", { length: 100 }),
linkMask: varchar("link_mask", { length: 250 }),
archiveMask: varchar("archive_mask", { length: 250 }),
});
// ---- Aliases (alternative titles per entry/release/language)
export const aliases = mysqlTable("aliases", {
entryId: int("entry_id").notNull(),
@@ -214,3 +254,91 @@ export const roles = mysqlTable("roles", {
labelId: int("label_id").notNull(),
roletypeId: char("roletype_id", { length: 1 }).notNull(),
});
// ---- Additional ZXDB schema coverage (lookups and content) ----
export const articletypes = mysqlTable("articletypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const articles = mysqlTable("articles", {
labelId: int("label_id").notNull(),
link: varchar("link", { length: 200 }).notNull(),
articletypeId: char("articletype_id", { length: 1 }).notNull(),
title: varchar("title", { length: 200 }),
languageId: char("language_id", { length: 2 }).notNull(),
writer: varchar("writer", { length: 200 }),
dateYear: smallint("date_year"),
});
export const categories = mysqlTable("categories", {
id: smallint("id").notNull().primaryKey(),
// DB column `text`
name: varchar("text", { length: 50 }).notNull(),
});
export const contenttypes = mysqlTable("contenttypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const contents = mysqlTable("contents", {
// ZXDB contents table does not have its own `id`; natural key is (issue_id, page_from, page_to, label_id, entry_id)
entryId: int("entry_id").notNull(),
labelId: int("label_id"),
issueId: int("issue_id").notNull(),
contenttypeId: char("contenttype_id", { length: 1 }).notNull(),
pageFrom: smallint("page_from"),
pageTo: smallint("page_to"),
title: varchar("title", { length: 200 }),
dateYear: smallint("date_year"),
rating: tinyint("rating"),
comments: varchar("comments", { length: 250 }),
});
export const extensions = mysqlTable("extensions", {
ext: varchar("ext", { length: 15 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const features = mysqlTable("features", {
id: int("id").notNull().primaryKey(),
name: varchar("name", { length: 150 }).notNull(),
version: tinyint("version").notNull().default(0),
labelId: int("label_id"),
label2Id: int("label2_id"),
});
export const tooltypes = mysqlTable("tooltypes", {
id: char("id", { length: 1 }).notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const tools = mysqlTable("tools", {
id: int("id").notNull().primaryKey(),
title: varchar("title", { length: 200 }).notNull(),
languageId: char("language_id", { length: 2 }),
tooltypeId: char("tooltype_id", { length: 1 }),
link: varchar("link", { length: 200 }),
});
// ---- Magazine references (per-issue references to entries/labels/topics) ----
export const referencetypes = mysqlTable("referencetypes", {
id: tinyint("id").notNull().primaryKey(),
name: varchar("text", { length: 50 }).notNull(),
});
export const magrefs = mysqlTable("magrefs", {
id: int("id").notNull().primaryKey(),
referencetypeId: tinyint("referencetype_id").notNull(),
entryId: int("entry_id"),
labelId: int("label_id"),
topicId: int("topic_id"),
issueId: int("issue_id").notNull(),
page: smallint("page").notNull().default(0),
isOriginal: tinyint("is_original").notNull().default(0),
scoreGroup: varchar("score_group", { length: 100 }).notNull().default(""),
reviewId: int("review_id"),
awardId: tinyint("award_id"),
});