Add entry_id relationship links to Entries

- Introduce reusable EntryLink component
- Use EntryLink in Releases and Label detail tables
- Link both ID and title to /zxdb/entries/[id] for consistency

Signed-off-by: Junie@MacOS
This commit is contained in:
2025-12-17 22:30:48 +00:00
parent 07478b280c
commit 2bade1825c
9 changed files with 382 additions and 6 deletions

View File

@@ -0,0 +1,18 @@
"use client";
import Link from "next/link";
type Props = {
id: number;
title?: string;
className?: string;
};
export default function EntryLink({ id, title, className }: Props) {
const text = typeof title === "string" && title.length > 0 ? title : `#${id}`;
return (
<Link href={`/zxdb/entries/${id}`} className={className}>
{text}
</Link>
);
}

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import EntryLink from "../components/EntryLink";
import { usePathname, useRouter } from "next/navigation";
type Item = {
@@ -244,9 +245,9 @@ export default function EntriesExplorer({
<tbody>
{data.items.map((it) => (
<tr key={it.id}>
<td>{it.id}</td>
<td><EntryLink id={it.id} /></td>
<td>
<Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link>
<EntryLink id={it.id} title={it.title} />
</td>
<td>
{it.machinetypeId != null ? (

View File

@@ -66,6 +66,9 @@ export type EntryDetailData = {
year: number | null;
}[];
}[];
// Additional relationships
aliases?: { releaseSeq: number; languageId: string; title: string }[];
webrefs?: { link: string; languageId: string; website: { id: number; name: string; link?: string | null } }[];
};
export default function EntryDetailClient({ data }: { data: EntryDetailData | null }) {
@@ -286,6 +289,74 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
<hr />
{/* Aliases (alternative titles) */}
<div>
<h5>Aliases</h5>
{(!data.aliases || data.aliases.length === 0) && <div className="text-secondary">No aliases</div>}
{data.aliases && data.aliases.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th style={{ width: 90 }}>Release #</th>
<th style={{ width: 120 }}>Language</th>
<th>Title</th>
</tr>
</thead>
<tbody>
{data.aliases.map((a, idx) => (
<tr key={`${a.releaseSeq}-${a.languageId}-${idx}`}>
<td>#{a.releaseSeq}</td>
<td>{a.languageId}</td>
<td>{a.title}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<hr />
{/* Web links (external references) */}
<div>
<h5>Web links</h5>
{(!data.webrefs || data.webrefs.length === 0) && <div className="text-secondary">No web links</div>}
{data.webrefs && data.webrefs.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Website</th>
<th style={{ width: 120 }}>Language</th>
<th>URL</th>
</tr>
</thead>
<tbody>
{data.webrefs.map((w, idx) => (
<tr key={`${w.website.id}-${idx}`}>
<td>
{w.website.link ? (
<a href={w.website.link} target="_blank" rel="noopener noreferrer">{w.website.name}</a>
) : (
<span>{w.website.name}</span>
)}
</td>
<td>{w.languageId}</td>
<td>
<a href={w.link} target="_blank" rel="noopener noreferrer">{w.link}</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<hr />
<div>
<h5>Files</h5>
{(!data.files || data.files.length === 0) && <div className="text-secondary">No files linked</div>}

View File

@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import EntryLink from "../../components/EntryLink";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
@@ -69,8 +70,8 @@ export default function LabelDetailClient({ id, initial, initialTab, initialQ }:
<tbody>
{current.items.map((it) => (
<tr key={it.id}>
<td>{it.id}</td>
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
<td><EntryLink id={it.id} /></td>
<td><EntryLink id={it.id} title={it.title} /></td>
<td>
{it.machinetypeId != null ? (
it.machinetypeName ? (

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import EntryLink from "../components/EntryLink";
import { usePathname, useRouter } from "next/navigation";
type Item = {
@@ -313,9 +314,11 @@ export default function ReleasesExplorer({
<tbody>
{data.items.map((it) => (
<tr key={`${it.entryId}-${it.releaseSeq}`}>
<td>{it.entryId}</td>
<td>
<Link href={`/zxdb/entries/${it.entryId}`}>{it.entryTitle}</Link>
<EntryLink id={it.entryId} />
</td>
<td>
<EntryLink id={it.entryId} title={it.entryTitle} />
</td>
<td>#{it.releaseSeq}</td>
<td>{it.year ?? <span className="text-secondary">-</span>}</td>

View File

@@ -20,6 +20,9 @@ import {
availabletypes,
currencies,
roletypes,
aliases,
webrefs,
websites,
} from "@/server/schema/zxdb";
export interface SearchParams {
@@ -219,6 +222,9 @@ export interface EntryDetail {
year: number | null;
}[];
}[];
// Additional relationships surfaced on the entry detail page
aliases?: { releaseSeq: number; languageId: string; title: string }[];
webrefs?: { link: string; languageId: string; website: { id: number; name: string; link?: string | null } }[];
}
export async function getEntryById(id: number): Promise<EntryDetail | null> {
@@ -411,6 +417,24 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
// Sort releases by sequence for stable UI order
releasesData.sort((a, b) => a.releaseSeq - b.releaseSeq);
// Fetch extra relationships in parallel
let aliasRows: { releaseSeq: number | string; languageId: string; title: string }[] = [];
let webrefRows: { link: string; languageId: string; websiteId: number | string; websiteName: string; websiteLink: string | null }[] = [];
try {
aliasRows = await db
.select({ releaseSeq: aliases.releaseSeq, languageId: aliases.languageId, title: aliases.title })
.from(aliases)
.where(eq(aliases.entryId, id));
} catch {}
try {
const rows = await db
.select({ link: webrefs.link, languageId: webrefs.languageId, websiteId: websites.id, websiteName: websites.name, websiteLink: websites.link })
.from(webrefs)
.innerJoin(websites, eq(websites.id, webrefs.websiteId))
.where(eq(webrefs.entryId, id));
webrefRows = rows as typeof webrefRows;
} catch {}
return {
id: base.id,
title: base.title,
@@ -453,6 +477,8 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
year: d.year != null ? Number(d.year) : null,
releaseSeq: Number(d.releaseSeq),
})),
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 } })),
};
}

View File

@@ -143,6 +143,14 @@ export const hosts = mysqlTable("hosts", {
magazineId: smallint("magazine_id"),
});
// ---- Aliases (alternative titles per entry/release/language)
export const aliases = mysqlTable("aliases", {
entryId: int("entry_id").notNull(),
releaseSeq: smallint("release_seq").notNull().default(0),
languageId: char("language_id", { length: 2 }).notNull(),
title: varchar("title", { length: 250 }).notNull(),
});
// `releases` are identified by (entry_id, release_seq)
export const releases = mysqlTable("releases", {
entryId: int("entry_id").notNull(),
@@ -184,6 +192,22 @@ export const downloads = mysqlTable("downloads", {
comments: varchar("comments", { length: 250 }),
});
// ---- Web references (external links tied to entries)
export const webrefs = mysqlTable("webrefs", {
entryId: int("entry_id").notNull(),
link: varchar("link", { length: 200 }).notNull(),
websiteId: tinyint("website_id").notNull(),
languageId: char("language_id", { length: 2 }).notNull(),
});
export const websites = mysqlTable("websites", {
id: tinyint("id").notNull().primaryKey(),
name: varchar("name", { length: 100 }).notNull(),
comments: varchar("comments", { length: 100 }),
link: varchar("link", { length: 100 }),
linkMask: varchar("link_mask", { length: 100 }),
});
// Roles relation (composite PK in DB)
export const roles = mysqlTable("roles", {
entryId: int("entry_id").notNull(),