Add ZXDB breadcrumbs and release places

Add ZXDB breadcrumbs on list/detail pages and group release
magazine references by issue for clearer Places view.

Signed-off-by: codex@lucy.xalior.com
This commit is contained in:
2026-01-10 17:35:36 +00:00
parent 5d140a45a7
commit 0594b34c62
12 changed files with 230 additions and 52 deletions

View File

@@ -0,0 +1,27 @@
import Link from "next/link";
type Crumb = {
label: string;
href?: string;
};
export default function ZxdbBreadcrumbs({ items }: { items: Crumb[] }) {
if (items.length === 0) return null;
const lastIndex = items.length - 1;
return (
<nav aria-label="breadcrumb">
<ol className="breadcrumb">
{items.map((item, index) => {
const isActive = index === lastIndex || !item.href;
return (
<li key={`${item.label}-${index}`} className={`breadcrumb-item${isActive ? " active" : ""}`} aria-current={isActive ? "page" : undefined}>
{isActive ? item.label : <Link href={item.href ?? "#"}>{item.label}</Link>}
</li>
);
})}
</ol>
</nav>
);
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import EntryLink from "../components/EntryLink";
import { usePathname, useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Item = {
id: number;
@@ -178,6 +179,13 @@ export default function EntriesExplorer({
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Entries" },
]}
/>
<h1 className="mb-3">Entries</h1>
<form className="row gy-2 gx-2 align-items-center" onSubmit={onSubmit}>
<div className="col-sm-8 col-md-6 col-lg-4">

View File

@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Label = { id: number; name: string; labeltypeId: string | null };
export type EntryDetailData = {
@@ -76,6 +77,14 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Entries", href: "/zxdb/entries" },
{ label: data.title },
]}
/>
<div className="d-flex align-items-center gap-2 flex-wrap">
<h1 className="mb-0">{data.title}</h1>
{data.genre.name && (
@@ -243,7 +252,9 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${d.machinetype.id}`}>{d.machinetype.name}</Link>
)}
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null}
<span className="badge text-bg-light">rel #{d.releaseSeq}</span>
<Link className="badge text-bg-light text-decoration-none" href={`/zxdb/releases/${data.id}/${d.releaseSeq}`}>
rel #{d.releaseSeq}
</Link>
</div>
</td>
<td>{d.comments ?? ""}</td>
@@ -306,7 +317,9 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
<tbody>
{data.aliases.map((a, idx) => (
<tr key={`${a.releaseSeq}-${a.languageId}-${idx}`}>
<td>#{a.releaseSeq}</td>
<td>
<Link href={`/zxdb/releases/${data.id}/${a.releaseSeq}`}>#{a.releaseSeq}</Link>
</td>
<td>{a.languageId}</td>
<td>{a.title}</td>
</tr>

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Genre = { id: number; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -31,6 +32,13 @@ export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Ge
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Genres" },
]}
/>
<h1>Genres</h1>
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
<div className="col-sm-8 col-md-6 col-lg-4">

View File

@@ -2,6 +2,7 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { getIssue } from "@/server/repo/zxdb";
import EntryLink from "@/app/zxdb/components/EntryLink";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
export const metadata = { title: "ZXDB Issue" };
export const revalidate = 3600;
@@ -18,6 +19,15 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Magazines", href: "/zxdb/magazines" },
{ label: issue.magazine.title, href: `/zxdb/magazines/${issue.magazine.id}` },
{ label: `Issue ${ym || issue.id}` },
]}
/>
<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>

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Label = { id: number; name: string; labeltypeId: string | null };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -33,6 +34,13 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<La
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Labels" },
]}
/>
<h1>Labels</h1>
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
<div className="col-sm-8 col-md-6 col-lg-4">

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Language = { id: string; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -31,6 +32,13 @@ export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Languages" },
]}
/>
<h1>Languages</h1>
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
<div className="col-sm-8 col-md-6 col-lg-4">

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type MT = { id: number; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -33,6 +34,13 @@ export default function MachineTypesSearch({ initial, initialQ }: { initial?: Pa
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Machine Types" },
]}
/>
<h1>Machine Types</h1>
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
<div className="col-sm-8 col-md-6 col-lg-4">

View File

@@ -1,6 +1,7 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getMagazine } from "@/server/repo/zxdb";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
export const metadata = { title: "ZXDB Magazine" };
export const revalidate = 3600;
@@ -15,6 +16,14 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Magazines", href: "/zxdb/magazines" },
{ label: mag.title },
]}
/>
<h1 className="mb-1">{mag.title}</h1>
<div className="text-secondary mb-3">Language: {mag.languageId}</div>

View File

@@ -1,5 +1,6 @@
import Link from "next/link";
import { listMagazines } from "@/server/repo/zxdb";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
export const metadata = { title: "ZXDB Magazines" };
@@ -19,6 +20,13 @@ export default async function Page({
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Magazines" },
]}
/>
<h1 className="mb-3">Magazines</h1>
<form className="mb-3" action="/zxdb/magazines" method="get">

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import EntryLink from "../components/EntryLink";
import { usePathname, useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Item = {
entryId: number;
@@ -229,6 +230,13 @@ export default function ReleasesExplorer({
return (
<div>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Releases" },
]}
/>
<h1 className="mb-3">Releases</h1>
<form className="row gy-2 gx-2 align-items-center" onSubmit={onSubmit}>
<div className="col-sm-8 col-md-6 col-lg-4">

View File

@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type ReleaseDetailData = {
entry: {
@@ -114,25 +115,69 @@ function formatCurrency(value: number | null, currency: ReleaseDetailData["relea
return String(value);
}
type MagazineGroup = {
magazineId: number | null;
magazineName: string | null;
items: ReleaseDetailData["magazineRefs"];
};
type IssueGroup = {
issueId: number;
issue: ReleaseDetailData["magazineRefs"][number]["issue"];
items: ReleaseDetailData["magazineRefs"];
};
function groupMagazineRefs(refs: ReleaseDetailData["magazineRefs"]) {
const groups: MagazineGroup[] = [];
const lookup = new Map<string, MagazineGroup>();
for (const ref of refs) {
const key = ref.magazineId != null ? `mag:${ref.magazineId}` : "mag:unknown";
let group = lookup.get(key);
if (!group) {
group = { magazineId: ref.magazineId, magazineName: ref.magazineName, items: [] };
lookup.set(key, group);
groups.push(group);
}
group.items.push(ref);
}
return groups;
}
function groupIssueRefs(refs: ReleaseDetailData["magazineRefs"]) {
const groups: IssueGroup[] = [];
const lookup = new Map<number, IssueGroup>();
for (const ref of refs) {
const key = ref.issueId;
let group = lookup.get(key);
if (!group) {
group = { issueId: ref.issueId, issue: ref.issue, items: [] };
lookup.set(key, group);
groups.push(group);
}
group.items.push(ref);
}
return groups;
}
export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData | null }) {
if (!data) return <div className="alert alert-warning">Not found</div>;
const magazineGroups = groupMagazineRefs(data.magazineRefs);
return (
<div>
<nav aria-label="breadcrumb">
<ol className="breadcrumb">
<li className="breadcrumb-item">
<Link href="/zxdb">ZXDB</Link>
</li>
<li className="breadcrumb-item">
<Link href="/zxdb/releases">Releases</Link>
</li>
<li className="breadcrumb-item">
<Link href={`/zxdb/entries/${data.entry.id}`}>{data.entry.title}</Link>
</li>
<li className="breadcrumb-item active" aria-current="page">Release #{data.release.releaseSeq}</li>
</ol>
</nav>
<ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Releases", href: "/zxdb/releases" },
{ label: data.entry.title, href: `/zxdb/entries/${data.entry.id}` },
{ label: `Release #${data.release.releaseSeq}` },
]}
/>
<div className="d-flex align-items-center gap-2 flex-wrap">
<h1 className="mb-0">Release #{data.release.releaseSeq}</h1>
@@ -221,37 +266,50 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
<hr />
<div>
<h5>Magazine References</h5>
{data.magazineRefs.length === 0 && <div className="text-secondary">No magazine references</div>}
{data.magazineRefs.length > 0 && (
<div className="table-responsive">
<h5>Places (Magazines)</h5>
{magazineGroups.length === 0 && <div className="text-secondary">No magazine references</div>}
{magazineGroups.length > 0 && (
<div className="d-flex flex-column gap-3">
{magazineGroups.map((group) => (
<div key={group.magazineId ?? "unknown"}>
<div className="d-flex align-items-center justify-content-between">
<div className="fw-semibold">
{group.magazineId != null ? (
<Link href={`/zxdb/magazines/${group.magazineId}`}>
{group.magazineName ?? `Magazine #${group.magazineId}`}
</Link>
) : (
<span className="text-secondary">Unknown magazine</span>
)}
</div>
<div className="text-secondary small">{group.items.length} reference{group.items.length === 1 ? "" : "s"}</div>
</div>
{groupIssueRefs(group.items).map((issueGroup) => (
<div key={issueGroup.issueId} className="mt-2">
<div className="d-flex align-items-center justify-content-between">
<div>
<Link href={`/zxdb/issues/${issueGroup.issueId}`}>Issue #{issueGroup.issueId}</Link>
<div className="text-secondary small">{formatIssue(issueGroup.issue) || "-"}</div>
</div>
<div className="text-secondary small">
{issueGroup.items.length} reference{issueGroup.items.length === 1 ? "" : "s"}
</div>
</div>
<div className="table-responsive mt-2">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Magazine</th>
<th>Issue</th>
<th style={{ width: 120 }}>Type</th>
<th style={{ width: 80 }}>Page</th>
<th style={{ width: 120 }}>Type</th>
<th style={{ width: 100 }}>Original</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{data.magazineRefs.map((m) => (
{issueGroup.items.map((m) => (
<tr key={m.id}>
<td>
{m.magazineId != null ? (
<Link href={`/zxdb/magazines/${m.magazineId}`}>{m.magazineName ?? `#${m.magazineId}`}</Link>
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
<Link href={`/zxdb/issues/${m.issueId}`}>#{m.issueId}</Link>
<div className="text-secondary small">{formatIssue(m.issue) || "-"}</div>
</td>
<td>{m.referencetypeName ?? `#${m.referencetypeId}`}</td>
<td>{m.page}</td>
<td>{m.referencetypeName ?? `#${m.referencetypeId}`}</td>
<td>{m.isOriginal ? "Yes" : "No"}</td>
<td>{m.scoreGroup || "-"}</td>
</tr>
@@ -259,6 +317,11 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
</tbody>
</table>
</div>
</div>
))}
</div>
))}
</div>
)}
</div>