15 Commits

Author SHA1 Message Date
48d02adbed Hunno this isn't all the case search fixes required...
Manual fixes for a lot of case places..

-Dx
2026-01-10 23:34:02 +00:00
9bb0a18695 Update setup docs and scripts
Refresh setup docs, add ZXDB local setup script, and note deploy rules.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 22:52:27 +00:00
89d48edbd9 Add deploy helper script
Add a deploy script and npm commands, and include
Navbar updates as requested.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 22:39:03 +00:00
0b0dced512 Revamp entry detail layout
Restructure entry detail into a two-column layout with
summary/people cards and section cards on the right.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 22:32:55 +00:00
e94492eab6 Unify ZXDB list layouts
Apply sidebar filter layout to label/genre/language/machine
lists and restructure release detail into a two-column view.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 22:05:28 +00:00
6f7ffa899d Refresh releases and magazines UI
Apply sidebar filter layout and header summary to releases
and magazines list pages.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 21:56:46 +00:00
84dee2710c Add genre column to entries
Include genre data in entry search results and show it
in the entries table layout.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 21:53:31 +00:00
5130a72641 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
2026-01-10 19:21:46 +00:00
964b48abf1 Add entry ports and scores
Surface ports, remakes, scores, and notes on entry detail.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 18:26:34 +00:00
d9f55c3eb6 Add entry relations and tags
Show relations and tag membership sections on entry detail.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 18:23:58 +00:00
06ddeba9bb Polish origins and guidelines
Add issue/magazine links and ordering to entry origins,
and document preferred validation guidance.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 18:18:20 +00:00
fb206734db Add ZXDB origins and label types
Show entry origins data and display label type names
in label detail view.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 18:12:30 +00:00
e2f6aac856 Expand ZXDB entry data and search
Add entry release/license sections, label permissions/licenses,
expanded search scope (titles+aliases+origins), and home search.
Also include ZXDB submodule and update gitignore.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 18:04:04 +00:00
3e13da5552 Improve ZXDB releases list
Link release titles to release detail, add magref count
badges, and show other releases on release detail.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 17:46:57 +00:00
0594b34c62 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
2026-01-10 17:35:36 +00:00
27 changed files with 2699 additions and 987 deletions

1
.gitignore vendored
View File

@@ -45,3 +45,4 @@ next-env.d.ts
.pnpm .pnpm
.pnpm-store .pnpm-store
ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql
bin/sync-downloads.mjs

View File

@@ -153,6 +153,16 @@ Comment what the code does, not what the agent has done. The documentation's pur
- Use imperative mood (e.g., "Add feature X", "Fix bug Y"). - Use imperative mood (e.g., "Add feature X", "Fix bug Y").
- Include relevant issue numbers if applicable. - Include relevant issue numbers if applicable.
- Sign-off commit message as <agent-name>@<hostname> - Sign-off commit message as <agent-name>@<hostname>
- validation and review:
- 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).
- Use the local `.env` for any environment-dependent behavior.
- Provide fully clickable links when sharing validation URLs.
- submodule hygiene:
- The `ZXDB` submodule is read-only in this repo; do not commit SQL dumps from it.
- Use `bin/setup-zxdb-local.sh` (or `pnpm setup:zxdb-local`) to add local excludes for SQL files.
- deploy workflow:
- `bin/deploy.sh` refuses to run with uncommitted or untracked files at the repo root.
### References ### References

View File

@@ -22,6 +22,9 @@ Project scripts (package.json)
- `dev`: `PORT=4000 next dev --turbopack` - `dev`: `PORT=4000 next dev --turbopack`
- `build`: `next build --turbopack` - `build`: `next build --turbopack`
- `start`: `next start` - `start`: `next start`
- `deploy`: merge current branch into `deploy` and push to `explorer.specnext.dev`
- `deploy:branch`: same as `deploy`, but accepts a deploy branch argument
- `setup:zxdb-local`: configure local submodule excludes for ZXDB SQL files
- `deploy-test`: push to `test.explorer.specnext.dev` - `deploy-test`: push to `test.explorer.specnext.dev`
- `deploy-prod`: push to `explorer.specnext.dev` - `deploy-prod`: push to `explorer.specnext.dev`
@@ -59,6 +62,10 @@ The Registers section works without any database. The ZXDB Explorer requires a M
3) Run the app 3) Run the app
- `pnpm dev` → open http://localhost:4000 and navigate to `/zxdb`. - `pnpm dev` → open http://localhost:4000 and navigate to `/zxdb`.
4) Keep the ZXDB submodule clean (recommended)
- Run `pnpm setup:zxdb-local` once after cloning.
- This keeps `ZXDB/ZXDB_mysql.sql` and `ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql` available locally without appearing as untracked changes.
API (selected endpoints) API (selected endpoints)
- `GET /api/zxdb/search?q=...&page=1&pageSize=20&genreId=...&languageId=...&machinetypeId=...&sort=title&facets=1` - `GET /api/zxdb/search?q=...&page=1&pageSize=20&genreId=...&languageId=...&machinetypeId=...&sort=title&facets=1`
- `GET /api/zxdb/entries/[id]` - `GET /api/zxdb/entries/[id]`

23
bin/deploy.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
deploy_branch="${1:-deploy}"
current_branch="$(git rev-parse --abbrev-ref HEAD)"
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Working tree is not clean. Commit or stash changes before deploy."
exit 1
fi
if git ls-files --others --exclude-standard | grep -q .; then
echo "Untracked files present. Commit or remove them before deploy."
exit 1
fi
cleanup() {
git checkout "${current_branch}" >/dev/null 2>&1 || true
}
trap cleanup EXIT
git checkout "${deploy_branch}"
git merge --no-edit "${current_branch}"
git push explorer.specnext.dev "${deploy_branch}"

18
bin/setup-zxdb-local.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
git_dir="$(git -C ZXDB rev-parse --git-dir)"
exclude_file="${git_dir}/info/exclude"
mkdir -p "$(dirname "${exclude_file}")"
touch "${exclude_file}"
add_exclude() {
local pattern="$1"
if ! grep -Fxq "${pattern}" "${exclude_file}"; then
printf "%s\n" "${pattern}" >> "${exclude_file}"
fi
}
add_exclude "ZXDB_mysql.sql"
add_exclude "ZXDB_mysql_STRUCTURE_ONLY.sql"

View File

@@ -4,13 +4,14 @@ This document explains how the ZXDB Explorer works in this project, how to set u
## What is ZXDB? ## What is ZXDB?
ZXDB ( https://github.com/zxdb/ZXDB )is a communitymaintained database of ZX Spectrum software, publications, and related entities. In this project, we connect to a MySQL ZXDB instance in readonly mode and expose a fast, crosslinked explorer UI under `/zxdb`. ZXDB (https://github.com/zxdb/ZXDB) is a communitymaintained database of ZX Spectrum software, publications, and related entities. In this project, we connect to a MySQL ZXDB instance in readonly mode and expose a fast, crosslinked explorer UI under `/zxdb`.
## Prerequisites ## Prerequisites
- MySQL server with ZXDB data (or at minimum the tables; data is needed to browse). - MySQL server with ZXDB data (or at minimum the tables; data is needed to browse).
- Ability to run the helper SQL that builds search tables (required for efficient LIKE searches). - Ability to run the helper SQL that builds search tables (required for efficient LIKE searches).
- A readonly MySQL user for the app (recommended). - A readonly MySQL user for the app (recommended).
- The `ZXDB` submodule is checked in for schemas/scripts; use `pnpm setup:zxdb-local` after cloning to keep local SQL dumps untracked.
## Database setup ## Database setup
@@ -19,7 +20,8 @@ ZXDB ( https://github.com/zxdb/ZXDB )is a communitymaintained database of ZX
2. Create helper search tables (required). 2. Create helper search tables (required).
- Run `https://github.com/zxdb/ZXDB/blob/master/scripts/ZXDB_help_search.sql` on your ZXDB database. - Run `https://github.com/zxdb/ZXDB/blob/master/scripts/ZXDB_help_search.sql` on your ZXDB database.
- This creates `search_by_titles`, `search_by_names`, `search_by_authors`, and `search_by_publishers` tables. - This creates `search_by_titles`, `search_by_names`, `search_by_authors`, `search_by_publishers`, `search_by_aliases`, `search_by_origins`,
`search_by_magrefs`, `search_by_magazines`, and `search_by_issues` tables used for search scopes and magazine references.
3. Create a readonly role/user (recommended). 3. Create a readonly role/user (recommended).
- Create user `zxdb_readonly`. - Create user `zxdb_readonly`.
@@ -48,9 +50,14 @@ pnpm dev
## Explorer UI overview ## Explorer UI overview
- `/zxdb` — Search entries by title and filter by genre, language, and machine type; sort and paginate results. - `/zxdb` — Search entries by title and filter by genre, language, and machine type; sort and paginate results.
- `/zxdb/entries/[id]` — Entry details with badges for genre/language/machine, and linked authors/publishers. - `/zxdb/entries` — Entries search with scope toggles (titles/aliases/origins) and facets.
- `/zxdb/labels` and `/zxdb/labels/[id]` — Browse/search labels (people/companies) and view authored/published entries. - `/zxdb/entries/[id]` — Entry details with related releases, downloads, origins, relations, and media.
- `/zxdb/releases` — Releases search + filters.
- `/zxdb/releases/[entryId]/[releaseSeq]` — Release detail: magazine references, downloads, scraps, and issue files.
- `/zxdb/labels` and `/zxdb/labels/[id]` — Browse/search labels (people/companies), permissions, licenses, and authored/published entries.
- `/zxdb/genres`, `/zxdb/languages`, `/zxdb/machinetypes` — Category hubs with linked detail pages listing entries. - `/zxdb/genres`, `/zxdb/languages`, `/zxdb/machinetypes` — Category hubs with linked detail pages listing entries.
- `/zxdb/magazines` and `/zxdb/magazines/[id]` — Magazine list and issue navigation.
- `/zxdb/issues/[id]` — Issue detail with contents and references.
Crosslinking: All entities are permalinks using stable IDs. Navigation uses Next `Link` so pages are prefetched. Crosslinking: All entities are permalinks using stable IDs. Navigation uses Next `Link` so pages are prefetched.
@@ -67,6 +74,7 @@ All endpoints are under `/api/zxdb` and validate inputs with Zod. Responses are
- `page`, `pageSize` — pagination (default pageSize=20, max=100) - `page`, `pageSize` — pagination (default pageSize=20, max=100)
- `genreId`, `languageId`, `machinetypeId` — optional filters - `genreId`, `languageId`, `machinetypeId` — optional filters
- `sort``title` or `id_desc` - `sort``title` or `id_desc`
- `scope``title`, `title_aliases`, or `title_aliases_origins`
- `facets` — boolean; if truthy, includes facet counts for genres/languages/machines - `facets` — boolean; if truthy, includes facet counts for genres/languages/machines
- Entry detail - Entry detail
@@ -99,12 +107,13 @@ Runtime: API routes declare `export const runtime = "nodejs"` to support `mysql2
## Troubleshooting ## Troubleshooting
- 400 from dynamic API routes: ensure you await `ctx.params` before Zod validation. - 400 from dynamic API routes: ensure you await `ctx.params` before Zod validation.
- Missing facets or scope toggles: ensure helper tables from `ZXDB_help_search.sql` exist.
- Unknown column errors for lookup names: ZXDB tables use column `text` for names; Drizzle schema must select `text` as `name`. - Unknown column errors for lookup names: ZXDB tables use column `text` for names; Drizzle schema must select `text` as `name`.
- Slow entry page: confirm serverrendering is active and ISR is set; client components should not fetch on the first paint when initial props are provided. - Slow entry page: confirm serverrendering is active and ISR is set; client components should not fetch on the first paint when initial props are provided.
- MySQL auth or network errors: verify `ZXDB_URL` and that your user has read permissions. - MySQL auth or network errors: verify `ZXDB_URL` and that your user has read permissions.
## Roadmap ## Roadmap
- Facet counts displayed in the `/zxdb` filter UI. - Issue-centric media grouping and richer magazine metadata.
- Breadcrumbs and additional a11y polish. - Additional cross-links for tags, relations, and permissions as UI expands.
- Media assets and download links per release (future). - A11y polish and higher-level navigation enhancements.

View File

@@ -15,6 +15,12 @@ Run in development
- Command: pnpm dev - Command: pnpm dev
- Then open: http://localhost:4000 - Then open: http://localhost:4000
ZXDB submodule local setup
- The ZXDB repo is a submodule used as a read-only reference for schemas/scripts.
- Some local SQL files are expected to exist but should stay untracked.
- Run: pnpm setup:zxdb-local
- This adds local excludes inside the submodule so `git status` stays clean.
Build and start (production) Build and start (production)
- Build: pnpm build - Build: pnpm build
- Start: pnpm start - Start: pnpm start
@@ -24,7 +30,9 @@ Lint
- pnpm lint - pnpm lint
Deployment shortcuts Deployment shortcuts
- Two scripts are available in package.json: - Use pnpm deploy (or pnpm deploy:branch) to merge the current branch into `deploy` and push to explorer.specnext.dev.
- The deploy script refuses to run if there are uncommitted or untracked files.
- One-step push helpers (if you prefer manual branch selection):
- pnpm deploy-test: push the current branch to test.explorer.specnext.dev - pnpm deploy-test: push the current branch to test.explorer.specnext.dev
- pnpm deploy-prod: push the current branch to explorer.specnext.dev - pnpm deploy-prod: push the current branch to explorer.specnext.dev
Ensure the corresponding Git remotes are configured locally before using these. - Ensure the corresponding Git remotes are configured locally before using these.

View File

@@ -5,5 +5,6 @@ Welcome to the Spectrum Next Explorer docs. This site provides an overview of th
- Getting Started: ./getting-started.md - Getting Started: ./getting-started.md
- Architecture: ./architecture.md - Architecture: ./architecture.md
- Register Explorer: ./registers.md - Register Explorer: ./registers.md
- ZXDB Explorer: ./ZXDB.md
If youre browsing on GitHub, the main README also links to these documents. If youre browsing on GitHub, the main README also links to these documents.

View File

@@ -7,6 +7,9 @@
"build": "next build --turbopack", "build": "next build --turbopack",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"deploy": "bin/deploy.sh",
"deploy:branch": "bin/deploy.sh",
"setup:zxdb-local": "bin/setup-zxdb-local.sh",
"deploy-prod": "git push --set-upstream explorer.specnext.dev deploy", "deploy-prod": "git push --set-upstream explorer.specnext.dev deploy",
"deploy-test": "git push --set-upstream test.explorer.specnext.dev test" "deploy-test": "git push --set-upstream test.explorer.specnext.dev test"
}, },

View File

@@ -14,6 +14,7 @@ const querySchema = z.object({
.optional(), .optional(),
machinetypeId: z.coerce.number().int().positive().optional(), machinetypeId: z.coerce.number().int().positive().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(),
facets: z.coerce.boolean().optional(), facets: z.coerce.boolean().optional(),
}); });
@@ -27,6 +28,7 @@ export async function GET(req: NextRequest) {
languageId: searchParams.get("languageId") ?? undefined, languageId: searchParams.get("languageId") ?? undefined,
machinetypeId: searchParams.get("machinetypeId") ?? undefined, machinetypeId: searchParams.get("machinetypeId") ?? undefined,
sort: searchParams.get("sort") ?? undefined, sort: searchParams.get("sort") ?? undefined,
scope: searchParams.get("scope") ?? undefined,
facets: searchParams.get("facets") ?? undefined, facets: searchParams.get("facets") ?? undefined,
}); });
if (!parsed.success) { if (!parsed.success) {

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,17 +4,22 @@ import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import EntryLink from "../components/EntryLink"; import EntryLink from "../components/EntryLink";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Item = { type Item = {
id: number; id: number;
title: string; title: string;
isXrated: number; isXrated: number;
genreId: number | null;
genreName?: string | null;
machinetypeId: number | null; machinetypeId: number | null;
machinetypeName?: string | null; machinetypeName?: string | null;
languageId: string | null; languageId: string | null;
languageName?: string | null; languageName?: string | null;
}; };
type SearchScope = "title" | "title_aliases" | "title_aliases_origins";
type Paged<T> = { type Paged<T> = {
items: T[]; items: T[];
page: number; page: number;
@@ -22,17 +27,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;
@@ -40,6 +54,7 @@ export default function EntriesExplorer({
languageId: string | ""; languageId: string | "";
machinetypeId: string | number | ""; machinetypeId: string | number | "";
sort: "title" | "id_desc"; sort: "title" | "id_desc";
scope?: SearchScope;
}; };
}) { }) {
const router = useRouter(); const router = useRouter();
@@ -60,9 +75,30 @@ export default function EntriesExplorer({
initialUrlState?.machinetypeId === "" ? "" : initialUrlState?.machinetypeId ? Number(initialUrlState.machinetypeId) : "" initialUrlState?.machinetypeId === "" ? "" : initialUrlState?.machinetypeId ? Number(initialUrlState.machinetypeId) : ""
); );
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 [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]);
const activeFilters = useMemo(() => {
const chips: string[] = [];
if (q) chips.push(`q: ${q}`);
if (genreId !== "") {
const name = genres.find((g) => g.id === Number(genreId))?.name ?? `#${genreId}`;
chips.push(`genre: ${name}`);
}
if (languageId !== "") {
const name = languages.find((l) => l.id === languageId)?.name ?? languageId;
chips.push(`lang: ${name}`);
}
if (machinetypeId !== "") {
const name = machines.find((m) => m.id === Number(machinetypeId))?.name ?? `#${machinetypeId}`;
chips.push(`machine: ${name}`);
}
if (scope === "title_aliases") chips.push("scope: titles + aliases");
if (scope === "title_aliases_origins") chips.push("scope: titles + aliases + origins");
return chips;
}, [q, genreId, languageId, machinetypeId, scope, genres, languages, machines]);
function updateUrl(nextPage = page) { function updateUrl(nextPage = page) {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -72,11 +108,12 @@ export default function EntriesExplorer({
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 (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
const qs = params.toString(); const qs = params.toString();
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();
@@ -87,10 +124,15 @@ export default function EntriesExplorer({
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 (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
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 });
@@ -119,15 +161,16 @@ export default function EntriesExplorer({
(initialUrlState?.languageId ?? "") === (languageId ?? "") && (initialUrlState?.languageId ?? "") === (languageId ?? "") &&
(initialUrlState?.machinetypeId === "" ? "" : Number(initialUrlState?.machinetypeId ?? "")) === (initialUrlState?.machinetypeId === "" ? "" : Number(initialUrlState?.machinetypeId ?? "")) ===
(machinetypeId === "" ? "" : Number(machinetypeId)) && (machinetypeId === "" ? "" : Number(machinetypeId)) &&
sort === (initialUrlState?.sort ?? "id_desc") sort === (initialUrlState?.sort ?? "id_desc") &&
(initialUrlState?.scope ?? "title") === scope
) { ) {
updateUrl(page); updateUrl(page);
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]); }, [page, genreId, languageId, machinetypeId, sort, scope]);
// Load filter lists on mount only if not provided by server // Load filter lists on mount only if not provided by server
useEffect(() => { useEffect(() => {
@@ -151,7 +194,17 @@ export default function EntriesExplorer({
e.preventDefault(); e.preventDefault();
setPage(1); setPage(1);
updateUrl(1); updateUrl(1);
fetchData(q, 1); fetchData(q, 1, true);
}
function resetFilters() {
setQ("");
setGenreId("");
setLanguageId("");
setMachinetypeId("");
setSort("id_desc");
setScope("title");
setPage(1);
} }
const prevHref = useMemo(() => { const prevHref = useMemo(() => {
@@ -162,8 +215,9 @@ export default function EntriesExplorer({
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 (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
return `/zxdb/entries?${params.toString()}`; return `/zxdb/entries?${params.toString()}`;
}, [q, data?.page, genreId, languageId, machinetypeId, sort]); }, [q, data?.page, genreId, languageId, machinetypeId, sort, scope]);
const nextHref = useMemo(() => { const nextHref = useMemo(() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -173,14 +227,45 @@ export default function EntriesExplorer({
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 (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
if (scope !== "title") params.set("scope", scope);
return `/zxdb/entries?${params.toString()}`; return `/zxdb/entries?${params.toString()}`;
}, [q, data?.page, genreId, languageId, machinetypeId, sort]); }, [q, data?.page, genreId, languageId, machinetypeId, sort, scope]);
return ( return (
<div> <div>
<h1 className="mb-3">Entries</h1> <ZxdbBreadcrumbs
<form className="row gy-2 gx-2 align-items-center" onSubmit={onSubmit}> items={[
<div className="col-sm-8 col-md-6 col-lg-4"> { label: "ZXDB", href: "/zxdb" },
{ label: "Entries" },
]}
/>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h1 className="mb-1">Entries</h1>
<div className="text-secondary">
{data ? `${data.total.toLocaleString()} results` : "Loading results..."}
</div>
</div>
{activeFilters.length > 0 && (
<div className="d-flex flex-wrap gap-2 align-items-center">
{activeFilters.map((chip) => (
<span key={chip} className="badge text-bg-light">{chip}</span>
))}
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={resetFilters}>
Clear filters
</button>
</div>
)}
</div>
<div className="row g-3">
<div className="col-lg-3">
<div className="card shadow-sm">
<div className="card-body">
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}>
<div>
<label className="form-label small text-secondary">Search</label>
<input <input
type="text" type="text"
className="form-control" className="form-control"
@@ -189,45 +274,83 @@ export default function EntriesExplorer({
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
/> />
</div> </div>
<div className="col-auto"> <div className="d-grid">
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button> <button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
</div> </div>
<div className="col-auto"> <div>
<label className="form-label small text-secondary">Genre</label>
<select className="form-select" value={genreId} onChange={(e) => { setGenreId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}> <select className="form-select" value={genreId} onChange={(e) => { setGenreId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
<option value="">Genre</option> <option value="">All genres</option>
{genres.map((g) => ( {genres.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option> <option key={g.id} value={g.id}>{g.name}</option>
))} ))}
</select> </select>
</div> </div>
<div className="col-auto"> <div>
<label className="form-label small text-secondary">Language</label>
<select className="form-select" value={languageId} onChange={(e) => { setLanguageId(e.target.value); setPage(1); }}> <select className="form-select" value={languageId} onChange={(e) => { setLanguageId(e.target.value); setPage(1); }}>
<option value="">Language</option> <option value="">All languages</option>
{languages.map((l) => ( {languages.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option> <option key={l.id} value={l.id}>{l.name}</option>
))} ))}
</select> </select>
</div> </div>
<div className="col-auto"> <div>
<label className="form-label small text-secondary">Machine</label>
<select className="form-select" value={machinetypeId} onChange={(e) => { setMachinetypeId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}> <select className="form-select" value={machinetypeId} onChange={(e) => { setMachinetypeId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
<option value="">Machine</option> <option value="">All machines</option>
{machines.map((m) => ( {machines.map((m) => (
<option key={m.id} value={m.id}>{m.name}</option> <option key={m.id} value={m.id}>{m.name}</option>
))} ))}
</select> </select>
</div> </div>
<div className="col-auto"> <div>
<label className="form-label small text-secondary">Sort</label>
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as "title" | "id_desc"); setPage(1); }}> <select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as "title" | "id_desc"); setPage(1); }}>
<option value="title">Sort: Title</option> <option value="title">Title (AZ)</option>
<option value="id_desc">Sort: Newest</option> <option value="id_desc">Newest</option>
</select> </select>
</div> </div>
{loading && ( <div>
<div className="col-auto text-secondary">Loading...</div> <label className="form-label small text-secondary">Search scope</label>
<select className="form-select" value={scope} onChange={(e) => { setScope(e.target.value as SearchScope); setPage(1); }}>
<option value="title">Titles</option>
<option value="title_aliases">Titles + Aliases</option>
<option value="title_aliases_origins">Titles + Aliases + Origins</option>
</select>
</div>
{facets && (
<div>
<div className="text-secondary small mb-1">Facets</div>
<div className="d-flex flex-wrap gap-2">
<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>
)} )}
{loading && <div className="text-secondary small">Loading...</div>}
</form> </form>
</div>
</div>
</div>
<div className="mt-3"> <div className="col-lg-9">
{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>
)} )}
@@ -236,18 +359,28 @@ export default function EntriesExplorer({
<table className="table table-striped table-hover align-middle"> <table className="table table-striped table-hover align-middle">
<thead> <thead>
<tr> <tr>
<th style={{width: 80}}>ID</th> <th style={{ width: 80 }}>ID</th>
<th>Title</th> <th>Title</th>
<th style={{width: 160}}>Machine</th> <th style={{ width: 160 }}>Genre</th>
<th style={{width: 120}}>Language</th> <th style={{ width: 160 }}>Machine</th>
<th style={{ width: 120 }}>Language</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.items.map((it) => ( {data.items.map((it) => (
<tr key={it.id}> <tr key={it.id}>
<td><EntryLink id={it.id} /></td> <td><EntryLink id={it.id} /></td>
<td><EntryLink id={it.id} title={it.title} /></td>
<td> <td>
<EntryLink id={it.id} title={it.title} /> {it.genreId != null ? (
it.genreName ? (
<Link href={`/zxdb/genres/${it.genreId}`}>{it.genreName}</Link>
) : (
<span>{it.genreId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td> </td>
<td> <td>
{it.machinetypeId != null ? ( {it.machinetypeId != null ? (
@@ -278,11 +411,10 @@ export default function EntriesExplorer({
</div> </div>
)} )}
</div> </div>
</div>
<div className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-4">
<span> <span>Page {data?.page ?? 1} / {totalPages}</span>
Page {data?.page ?? 1} / {totalPages}
</span>
<div className="ms-auto d-flex gap-2"> <div className="ms-auto d-flex gap-2">
<Link <Link
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`} className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Label = { id: number; name: string; labeltypeId: string | null }; type Label = { id: number; name: string; labeltypeId: string | null };
export type EntryDetailData = { export type EntryDetailData = {
@@ -12,6 +13,66 @@ export type EntryDetailData = {
genre: { id: number | null; name: string | null }; genre: { id: number | null; name: string | null };
authors: Label[]; authors: Label[];
publishers: Label[]; publishers: Label[];
licenses?: {
id: number;
name: string;
type: { id: string; name: string | null };
isOfficial: boolean;
linkWikipedia?: string | null;
linkSite?: string | null;
comments?: string | null;
}[];
relations?: {
direction: "from" | "to";
type: { id: string; name: string | null };
entry: { id: number; title: string | null };
}[];
tags?: {
id: number;
name: string;
type: { id: string; name: string | null };
category: { id: number | null; name: string | null };
memberSeq: number | null;
link: string | null;
comments: string | null;
}[];
ports?: {
id: number;
title: string | null;
platform: { id: number; name: string | null };
isOfficial: boolean;
linkSystem: string | null;
}[];
remakes?: {
id: number;
title: string;
fileLink: string;
fileDate: string | null;
fileSize: number | null;
authors: string | null;
platforms: string | null;
remakeYears: string | null;
remakeStatus: string | null;
}[];
scores?: {
website: { id: number; name: string | null };
score: number;
votes: number;
}[];
notes?: {
id: number;
type: { id: string; name: string | null };
text: string;
}[];
origins?: {
type: { id: string; name: string | null };
libraryTitle: string;
publication: string | null;
containerId: number | null;
issueId: number | null;
issue: { id: number; magazineId: number | null; magazineTitle: string | null } | null;
date: { year: number | null; month: number | null; day: number | null };
}[];
// extra fields for richer details // extra fields for richer details
maxPlayers?: number; maxPlayers?: number;
availabletypeId?: string | null; availabletypeId?: string | null;
@@ -76,6 +137,14 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
return ( return (
<div> <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"> <div className="d-flex align-items-center gap-2 flex-wrap">
<h1 className="mb-0">{data.title}</h1> <h1 className="mb-0">{data.title}</h1>
{data.genre.name && ( {data.genre.name && (
@@ -96,27 +165,24 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
{data.isXrated ? <span className="badge text-bg-danger">18+</span> : null} {data.isXrated ? <span className="badge text-bg-danger">18+</span> : null}
</div> </div>
<hr /> <div className="row g-3 mt-2">
<div className="col-lg-4">
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Entry Summary</h5>
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped table-hover align-middle"> <table className="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th style={{ width: 220 }}>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody> <tbody>
<tr> <tr>
<td>ID</td> <th style={{ width: 180 }}>ID</th>
<td>{data.id}</td> <td>{data.id}</td>
</tr> </tr>
<tr> <tr>
<td>Title</td> <th>Title</th>
<td>{data.title}</td> <td>{data.title}</td>
</tr> </tr>
<tr> <tr>
<td>Machine</td> <th>Machine</th>
<td> <td>
{data.machinetype.id != null ? ( {data.machinetype.id != null ? (
data.machinetype.name ? ( data.machinetype.name ? (
@@ -130,7 +196,7 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Language</td> <th>Language</th>
<td> <td>
{data.language.id ? ( {data.language.id ? (
data.language.name ? ( data.language.name ? (
@@ -144,7 +210,7 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Genre</td> <th>Genre</th>
<td> <td>
{data.genre.id ? ( {data.genre.id ? (
data.genre.name ? ( data.genre.name ? (
@@ -159,43 +225,86 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
</tr> </tr>
{typeof data.maxPlayers !== "undefined" && ( {typeof data.maxPlayers !== "undefined" && (
<tr> <tr>
<td>Max Players</td> <th>Max Players</th>
<td>{data.maxPlayers}</td> <td>{data.maxPlayers}</td>
</tr> </tr>
)} )}
{typeof data.availabletypeId !== "undefined" && ( {typeof data.availabletypeId !== "undefined" && (
<tr> <tr>
<td>Available Type</td> <th>Available Type</th>
<td>{data.availabletypeId ?? <span className="text-secondary">-</span>}</td> <td>{data.availabletypeId ?? <span className="text-secondary">-</span>}</td>
</tr> </tr>
)} )}
{typeof data.withoutLoadScreen !== "undefined" && ( {typeof data.withoutLoadScreen !== "undefined" && (
<tr> <tr>
<td>Without Load Screen</td> <th>Without Load Screen</th>
<td>{data.withoutLoadScreen ? "Yes" : "No"}</td> <td>{data.withoutLoadScreen ? "Yes" : "No"}</td>
</tr> </tr>
)} )}
{typeof data.withoutInlay !== "undefined" && ( {typeof data.withoutInlay !== "undefined" && (
<tr> <tr>
<td>Without Inlay</td> <th>Without Inlay</th>
<td>{data.withoutInlay ? "Yes" : "No"}</td> <td>{data.withoutInlay ? "Yes" : "No"}</td>
</tr> </tr>
)} )}
{typeof data.issueId !== "undefined" && ( {typeof data.issueId !== "undefined" && (
<tr> <tr>
<td>Issue</td> <th>Issue</th>
<td>{data.issueId ? <span>#{data.issueId}</span> : <span className="text-secondary">-</span>}</td> <td>{data.issueId ? <span>#{data.issueId}</span> : <span className="text-secondary">-</span>}</td>
</tr> </tr>
)} )}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</div>
<hr /> <div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">People</h5>
<div className="row g-3">
<div className="col-12">
<div className="text-secondary small mb-1">Authors</div>
{data.authors.length === 0 && <div className="text-secondary">Unknown</div>}
{data.authors.length > 0 && (
<ul className="list-unstyled mb-0">
{data.authors.map((a) => (
<li key={a.id}>
<Link href={`/zxdb/labels/${a.id}`}>{a.name}</Link>
</li>
))}
</ul>
)}
</div>
<div className="col-12">
<div className="text-secondary small mb-1">Publishers</div>
{data.publishers.length === 0 && <div className="text-secondary">Unknown</div>}
{data.publishers.length > 0 && (
<ul className="list-unstyled mb-0">
{data.publishers.map((p) => (
<li key={p.id}>
<Link href={`/zxdb/labels/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
{/* Downloads (flat, by entry_id). Render only this flat section; do not render grouped downloads here. */} <div className="card shadow-sm">
<div> <div className="card-body d-flex flex-wrap gap-2">
<h5>Downloads</h5> <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>
</div>
</div>
</div>
<div className="col-lg-8">
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Downloads</h5>
{(!data.downloadsFlat || data.downloadsFlat.length === 0) && <div className="text-secondary">No downloads</div>} {(!data.downloadsFlat || data.downloadsFlat.length === 0) && <div className="text-secondary">No downloads</div>}
{data.downloadsFlat && data.downloadsFlat.length > 0 && ( {data.downloadsFlat && data.downloadsFlat.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
@@ -243,7 +352,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> <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} {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> </div>
</td> </td>
<td>{d.comments ?? ""}</td> <td>{d.comments ?? ""}</td>
@@ -255,43 +366,302 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
</div> </div>
)} )}
</div> </div>
<hr />
<div className="row g-4">
<div className="col-lg-6">
<h5>Authors</h5>
{data.authors.length === 0 && <div className="text-secondary">Unknown</div>}
{data.authors.length > 0 && (
<ul className="list-unstyled mb-0">
{data.authors.map((a) => (
<li key={a.id}>
<Link href={`/zxdb/labels/${a.id}`}>{a.name}</Link>
</li>
))}
</ul>
)}
</div> </div>
<div className="col-lg-6">
<h5>Publishers</h5> <div className="card shadow-sm mb-3">
{data.publishers.length === 0 && <div className="text-secondary">Unknown</div>} <div className="card-body">
{data.publishers.length > 0 && ( <h5 className="card-title">Releases</h5>
<ul className="list-unstyled mb-0"> {(!data.releases || data.releases.length === 0) && <div className="text-secondary">No releases recorded</div>}
{data.publishers.map((p) => ( {data.releases && data.releases.length > 0 && (
<li key={p.id}> <div className="table-responsive">
<Link href={`/zxdb/labels/${p.id}`}>{p.name}</Link> <table className="table table-sm table-striped align-middle">
</li> <thead>
<tr>
<th style={{ width: 120 }}>Release #</th>
<th style={{ width: 120 }}>Year</th>
<th>Downloads</th>
</tr>
</thead>
<tbody>
{data.releases.map((r) => (
<tr key={r.releaseSeq}>
<td>
<Link href={`/zxdb/releases/${data.id}/${r.releaseSeq}`}>#{r.releaseSeq}</Link>
</td>
<td>{r.year ?? <span className="text-secondary">-</span>}</td>
<td>{r.downloads.length}</td>
</tr>
))} ))}
</ul> </tbody>
</table>
</div>
)} )}
</div> </div>
</div> </div>
<hr /> <div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Origins</h5>
{(!data.origins || data.origins.length === 0) && <div className="text-secondary">No origins recorded</div>}
{data.origins && data.origins.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Type</th>
<th>Title</th>
<th>Publication</th>
<th style={{ width: 200 }}>Issue</th>
<th style={{ width: 140 }}>Date</th>
</tr>
</thead>
<tbody>
{data.origins.map((o, idx) => {
const dateParts = [o.date.year, o.date.month, o.date.day]
.filter((v) => typeof v === "number" && Number.isFinite(v))
.map((v, i) => (i === 0 ? String(v) : String(v).padStart(2, "0")));
const dateText = dateParts.length ? dateParts.join("/") : "-";
return (
<tr key={`${o.type.id}-${idx}`}>
<td>{o.type.name ?? o.type.id}</td>
<td>{o.libraryTitle}</td>
<td>{o.publication ?? <span className="text-secondary">-</span>}</td>
<td>
{o.issue ? (
<div className="d-flex flex-column">
<Link href={`/zxdb/issues/${o.issue.id}`}>Issue #{o.issue.id}</Link>
{o.issue.magazineId != null && (
<Link className="text-secondary small" href={`/zxdb/magazines/${o.issue.magazineId}`}>
{o.issue.magazineTitle ?? `Magazine #${o.issue.magazineId}`}
</Link>
)}
</div>
) : o.containerId ? (
<span>Container #{o.containerId}</span>
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>{dateText}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Aliases (alternative titles) */} <div className="card shadow-sm mb-3">
<div> <div className="card-body">
<h5>Aliases</h5> <h5 className="card-title">Relations</h5>
{(!data.relations || data.relations.length === 0) && <div className="text-secondary">No relations recorded</div>}
{data.relations && data.relations.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th style={{ width: 90 }}>Direction</th>
<th style={{ width: 160 }}>Type</th>
<th>Entry</th>
</tr>
</thead>
<tbody>
{data.relations.map((r, idx) => (
<tr key={`${r.entry.id}-${r.type.id}-${idx}`}>
<td>{r.direction === "from" ? "From" : "To"}</td>
<td>{r.type.name ?? r.type.id}</td>
<td>
<Link href={`/zxdb/entries/${r.entry.id}`}>
{r.entry.title ?? `Entry #${r.entry.id}`}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Tags / Members</h5>
{(!data.tags || data.tags.length === 0) && <div className="text-secondary">No tags recorded</div>}
{data.tags && data.tags.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Tag</th>
<th style={{ width: 140 }}>Type</th>
<th style={{ width: 140 }}>Category</th>
<th style={{ width: 120 }}>Member Seq</th>
<th>Links</th>
</tr>
</thead>
<tbody>
{data.tags.map((t) => (
<tr key={`${t.id}-${t.category.id ?? "none"}`}>
<td>{t.name}</td>
<td>{t.type.name ?? t.type.id}</td>
<td>{t.category.name ?? (t.category.id != null ? `#${t.category.id}` : "-")}</td>
<td>{t.memberSeq ?? <span className="text-secondary">-</span>}</td>
<td>
<div className="d-flex gap-2 flex-wrap">
{t.link && (
<a href={t.link} target="_blank" rel="noreferrer">Link</a>
)}
{t.comments && <span className="text-secondary">{t.comments}</span>}
{!t.link && !t.comments && <span className="text-secondary">-</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Ports</h5>
{(!data.ports || data.ports.length === 0) && <div className="text-secondary">No ports recorded</div>}
{data.ports && data.ports.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Title</th>
<th style={{ width: 160 }}>Platform</th>
<th style={{ width: 120 }}>Official</th>
<th>Link</th>
</tr>
</thead>
<tbody>
{data.ports.map((p) => (
<tr key={p.id}>
<td>{p.title ?? <span className="text-secondary">-</span>}</td>
<td>{p.platform.name ?? `#${p.platform.id}`}</td>
<td>{p.isOfficial ? "Yes" : "No"}</td>
<td>
{p.linkSystem ? (
<a href={p.linkSystem} target="_blank" rel="noreferrer">Link</a>
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Remakes</h5>
{(!data.remakes || data.remakes.length === 0) && <div className="text-secondary">No remakes recorded</div>}
{data.remakes && data.remakes.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Title</th>
<th style={{ width: 160 }}>Platforms</th>
<th style={{ width: 140 }}>Years</th>
<th style={{ width: 140 }}>File</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{data.remakes.map((r) => (
<tr key={r.id}>
<td>{r.title}</td>
<td>{r.platforms ?? <span className="text-secondary">-</span>}</td>
<td>{r.remakeYears ?? <span className="text-secondary">-</span>}</td>
<td>
{r.fileLink ? (
<a href={r.fileLink} target="_blank" rel="noreferrer">File</a>
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>{r.remakeStatus ?? r.authors ?? <span className="text-secondary">-</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Scores</h5>
{(!data.scores || data.scores.length === 0) && <div className="text-secondary">No scores recorded</div>}
{data.scores && data.scores.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Website</th>
<th style={{ width: 120 }}>Score</th>
<th style={{ width: 120 }}>Votes</th>
</tr>
</thead>
<tbody>
{data.scores.map((s, idx) => (
<tr key={`${s.website.id}-${idx}`}>
<td>{s.website.name ?? `#${s.website.id}`}</td>
<td>{s.score}</td>
<td>{s.votes}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Notes</h5>
{(!data.notes || data.notes.length === 0) && <div className="text-secondary">No notes recorded</div>}
{data.notes && data.notes.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th style={{ width: 140 }}>Type</th>
<th>Text</th>
</tr>
</thead>
<tbody>
{data.notes.map((n) => (
<tr key={n.id}>
<td>{n.type.name ?? n.type.id}</td>
<td>{n.text}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Aliases</h5>
{(!data.aliases || data.aliases.length === 0) && <div className="text-secondary">No aliases</div>} {(!data.aliases || data.aliases.length === 0) && <div className="text-secondary">No aliases</div>}
{data.aliases && data.aliases.length > 0 && ( {data.aliases && data.aliases.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
@@ -306,7 +676,9 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
<tbody> <tbody>
{data.aliases.map((a, idx) => ( {data.aliases.map((a, idx) => (
<tr key={`${a.releaseSeq}-${a.languageId}-${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.languageId}</td>
<td>{a.title}</td> <td>{a.title}</td>
</tr> </tr>
@@ -316,12 +688,52 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
</div> </div>
)} )}
</div> </div>
</div>
<hr /> <div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Licenses</h5>
{(!data.licenses || data.licenses.length === 0) && <div className="text-secondary">No licenses linked</div>}
{data.licenses && data.licenses.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Name</th>
<th style={{ width: 140 }}>Type</th>
<th style={{ width: 120 }}>Official</th>
<th>Links</th>
</tr>
</thead>
<tbody>
{data.licenses.map((l) => (
<tr key={l.id}>
<td>{l.name}</td>
<td>{l.type.name ?? l.type.id}</td>
<td>{l.isOfficial ? "Yes" : "No"}</td>
<td>
<div className="d-flex gap-2 flex-wrap">
{l.linkWikipedia && (
<a href={l.linkWikipedia} target="_blank" rel="noreferrer">Wikipedia</a>
)}
{l.linkSite && (
<a href={l.linkSite} target="_blank" rel="noreferrer">Site</a>
)}
{!l.linkWikipedia && !l.linkSite && <span className="text-secondary">-</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Web links (external references) */} <div className="card shadow-sm mb-3">
<div> <div className="card-body">
<h5>Web links</h5> <h5 className="card-title">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="text-secondary">No web links</div>}
{data.webrefs && data.webrefs.length > 0 && ( {data.webrefs && data.webrefs.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
@@ -354,11 +766,11 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
</div> </div>
)} )}
</div> </div>
</div>
<hr /> <div className="card shadow-sm">
<div className="card-body">
<div> <h5 className="card-title">Files</h5>
<h5>Files</h5>
{(!data.files || data.files.length === 0) && <div className="text-secondary">No files linked</div>} {(!data.files || data.files.length === 0) && <div className="text-secondary">No files linked</div>}
{data.files && data.files.length > 0 && ( {data.files && data.files.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
@@ -396,14 +808,8 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
</div> </div>
)} )}
</div> </div>
</div>
<hr /> </div>
{/* Removed grouped releases/downloads section to avoid duplicate downloads UI. */}
<div className="d-flex align-items-center gap-2">
<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>
</div> </div>
</div> </div>
); );

View File

@@ -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",
@@ -15,13 +15,18 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
const machinetypeId = (Array.isArray(sp.machinetypeId) ? sp.machinetypeId[0] : sp.machinetypeId) ?? ""; const machinetypeId = (Array.isArray(sp.machinetypeId) ? sp.machinetypeId[0] : sp.machinetypeId) ?? "";
const sort = ((Array.isArray(sp.sort) ? sp.sort[0] : sp.sort) ?? "id_desc") as "title" | "id_desc"; const sort = ((Array.isArray(sp.sort) ? sp.sort[0] : sp.sort) ?? "id_desc") as "title" | "id_desc";
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? ""; const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const scope = ((Array.isArray(sp.scope) ? sp.scope[0] : sp.scope) ?? "title") as
| "title"
| "title_aliases"
| "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,
sort, sort,
q, q,
scope,
genreId: genreId ? Number(genreId) : undefined, genreId: genreId ? Number(genreId) : undefined,
languageId: languageId || undefined, languageId: languageId || undefined,
machinetypeId: machinetypeId ? Number(machinetypeId) : undefined, machinetypeId: machinetypeId ? Number(machinetypeId) : undefined,
@@ -29,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 (
@@ -37,7 +50,8 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
initialGenres={genres} initialGenres={genres}
initialLanguages={langs} initialLanguages={langs}
initialMachines={machines} initialMachines={machines}
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort }} initialFacets={facets}
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort, scope }}
/> />
); );
} }

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Genre = { id: number; name: string }; type Genre = { id: number; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -31,17 +32,38 @@ export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Ge
return ( return (
<div> <div>
<h1>Genres</h1> <ZxdbBreadcrumbs
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}> items={[
<div className="col-sm-8 col-md-6 col-lg-4"> { label: "ZXDB", href: "/zxdb" },
{ label: "Genres" },
]}
/>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h1 className="mb-1">Genres</h1>
<div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
</div>
</div>
<div className="row g-3">
<div className="col-lg-3">
<div className="card shadow-sm">
<div className="card-body">
<form className="d-flex flex-column gap-2" onSubmit={submit}>
<div>
<label className="form-label small text-secondary">Search</label>
<input className="form-control" placeholder="Search genres…" value={q} onChange={(e) => setQ(e.target.value)} /> <input className="form-control" placeholder="Search genres…" value={q} onChange={(e) => setQ(e.target.value)} />
</div> </div>
<div className="col-auto"> <div className="d-grid">
<button className="btn btn-primary">Search</button> <button className="btn btn-primary">Search</button>
</div> </div>
</form> </form>
</div>
</div>
</div>
<div className="mt-3"> <div className="col-lg-9">
{data && data.items.length === 0 && <div className="alert alert-warning">No genres found.</div>} {data && data.items.length === 0 && <div className="alert alert-warning">No genres found.</div>}
{data && data.items.length > 0 && ( {data && data.items.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
@@ -66,6 +88,7 @@ export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Ge
</div> </div>
)} )}
</div> </div>
</div>
<div className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-2">
<span>Page {data?.page ?? 1} / {totalPages}</span> <span>Page {data?.page ?? 1} / {totalPages}</span>

View File

@@ -2,6 +2,7 @@ import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getIssue } from "@/server/repo/zxdb"; import { getIssue } from "@/server/repo/zxdb";
import EntryLink from "@/app/zxdb/components/EntryLink"; import EntryLink from "@/app/zxdb/components/EntryLink";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
export const metadata = { title: "ZXDB Issue" }; export const metadata = { title: "ZXDB Issue" };
export const revalidate = 3600; export const revalidate = 3600;
@@ -18,6 +19,15 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
return ( return (
<div> <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"> <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/${issue.magazine.id}`}> Back to magazine</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">All magazines</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 { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Label = { id: number; name: string; labeltypeId: string | null }; type Label = { id: number; name: string; labeltypeId: string | null };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -33,17 +34,38 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<La
return ( return (
<div> <div>
<h1>Labels</h1> <ZxdbBreadcrumbs
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}> items={[
<div className="col-sm-8 col-md-6 col-lg-4"> { label: "ZXDB", href: "/zxdb" },
{ label: "Labels" },
]}
/>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h1 className="mb-1">Labels</h1>
<div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
</div>
</div>
<div className="row g-3">
<div className="col-lg-3">
<div className="card shadow-sm">
<div className="card-body">
<form className="d-flex flex-column gap-2" onSubmit={submit}>
<div>
<label className="form-label small text-secondary">Search</label>
<input className="form-control" placeholder="Search labels…" value={q} onChange={(e) => setQ(e.target.value)} /> <input className="form-control" placeholder="Search labels…" value={q} onChange={(e) => setQ(e.target.value)} />
</div> </div>
<div className="col-auto"> <div className="d-grid">
<button className="btn btn-primary">Search</button> <button className="btn btn-primary">Search</button>
</div> </div>
</form> </form>
</div>
</div>
</div>
<div className="mt-3"> <div className="col-lg-9">
{data && data.items.length === 0 && <div className="alert alert-warning">No labels found.</div>} {data && data.items.length === 0 && <div className="alert alert-warning">No labels found.</div>}
{data && data.items.length > 0 && ( {data && data.items.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
@@ -72,6 +94,7 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<La
</div> </div>
)} )}
</div> </div>
</div>
<div className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-2">
<span>Page {data?.page ?? 1} / {totalPages}</span> <span>Page {data?.page ?? 1} / {totalPages}</span>

View File

@@ -5,7 +5,25 @@ import EntryLink from "../../components/EntryLink";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
type Label = { id: number; name: string; labeltypeId: string | null }; type Label = {
id: number;
name: string;
labeltypeId: string | null;
labeltypeName: string | null;
permissions: {
website: { id: number; name: string; link?: string | null };
type: { id: string; name: string | null };
text: string | null;
}[];
licenses: {
id: number;
name: string;
type: { id: string; name: string | null };
linkWikipedia?: string | null;
linkSite?: string | null;
comments?: string | null;
}[];
};
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null }; type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -32,7 +50,82 @@ export default function LabelDetailClient({ id, initial, initialTab, initialQ }:
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2"> <div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
<h1 className="mb-0">{initial.label.name}</h1> <h1 className="mb-0">{initial.label.name}</h1>
<div> <div>
<span className="badge text-bg-light">{initial.label.labeltypeId ?? "?"}</span> <span className="badge text-bg-light">
{initial.label.labeltypeName
? `${initial.label.labeltypeName} (${initial.label.labeltypeId ?? "?"})`
: (initial.label.labeltypeId ?? "?")}
</span>
</div>
</div>
<div className="row g-4 mt-1">
<div className="col-lg-6">
<h5>Permissions</h5>
{initial.label.permissions.length === 0 && <div className="text-secondary">No permissions recorded</div>}
{initial.label.permissions.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Website</th>
<th style={{ width: 140 }}>Type</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{initial.label.permissions.map((p, idx) => (
<tr key={`${p.website.id}-${p.type.id}-${idx}`}>
<td>
{p.website.link ? (
<a href={p.website.link} target="_blank" rel="noreferrer">{p.website.name}</a>
) : (
<span>{p.website.name}</span>
)}
</td>
<td>{p.type.name ?? p.type.id}</td>
<td>{p.text ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="col-lg-6">
<h5>Licenses</h5>
{initial.label.licenses.length === 0 && <div className="text-secondary">No licenses linked</div>}
{initial.label.licenses.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Name</th>
<th style={{ width: 140 }}>Type</th>
<th>Links</th>
</tr>
</thead>
<tbody>
{initial.label.licenses.map((l) => (
<tr key={l.id}>
<td>{l.name}</td>
<td>{l.type.name ?? l.type.id}</td>
<td>
<div className="d-flex gap-2 flex-wrap">
{l.linkWikipedia && (
<a href={l.linkWikipedia} target="_blank" rel="noreferrer">Wikipedia</a>
)}
{l.linkSite && (
<a href={l.linkSite} target="_blank" rel="noreferrer">Site</a>
)}
{!l.linkWikipedia && !l.linkSite && <span className="text-secondary">-</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Language = { id: string; name: string }; type Language = { id: string; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -31,17 +32,38 @@ export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged
return ( return (
<div> <div>
<h1>Languages</h1> <ZxdbBreadcrumbs
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}> items={[
<div className="col-sm-8 col-md-6 col-lg-4"> { label: "ZXDB", href: "/zxdb" },
{ label: "Languages" },
]}
/>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h1 className="mb-1">Languages</h1>
<div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
</div>
</div>
<div className="row g-3">
<div className="col-lg-3">
<div className="card shadow-sm">
<div className="card-body">
<form className="d-flex flex-column gap-2" onSubmit={submit}>
<div>
<label className="form-label small text-secondary">Search</label>
<input className="form-control" placeholder="Search languages…" value={q} onChange={(e) => setQ(e.target.value)} /> <input className="form-control" placeholder="Search languages…" value={q} onChange={(e) => setQ(e.target.value)} />
</div> </div>
<div className="col-auto"> <div className="d-grid">
<button className="btn btn-primary">Search</button> <button className="btn btn-primary">Search</button>
</div> </div>
</form> </form>
</div>
</div>
</div>
<div className="mt-3"> <div className="col-lg-9">
{data && data.items.length === 0 && <div className="alert alert-warning">No languages found.</div>} {data && data.items.length === 0 && <div className="alert alert-warning">No languages found.</div>}
{data && data.items.length > 0 && ( {data && data.items.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
@@ -66,6 +88,7 @@ export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged
</div> </div>
)} )}
</div> </div>
</div>
<div className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-2">
<span>Page {data?.page ?? 1} / {totalPages}</span> <span>Page {data?.page ?? 1} / {totalPages}</span>

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type MT = { id: number; name: string }; type MT = { id: number; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -33,17 +34,38 @@ export default function MachineTypesSearch({ initial, initialQ }: { initial?: Pa
return ( return (
<div> <div>
<h1>Machine Types</h1> <ZxdbBreadcrumbs
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}> items={[
<div className="col-sm-8 col-md-6 col-lg-4"> { label: "ZXDB", href: "/zxdb" },
{ label: "Machine Types" },
]}
/>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h1 className="mb-1">Machine Types</h1>
<div className="text-secondary">{data?.total.toLocaleString() ?? "0"} results</div>
</div>
</div>
<div className="row g-3">
<div className="col-lg-3">
<div className="card shadow-sm">
<div className="card-body">
<form className="d-flex flex-column gap-2" onSubmit={submit}>
<div>
<label className="form-label small text-secondary">Search</label>
<input className="form-control" placeholder="Search machine types…" value={q} onChange={(e) => setQ(e.target.value)} /> <input className="form-control" placeholder="Search machine types…" value={q} onChange={(e) => setQ(e.target.value)} />
</div> </div>
<div className="col-auto"> <div className="d-grid">
<button className="btn btn-primary">Search</button> <button className="btn btn-primary">Search</button>
</div> </div>
</form> </form>
</div>
</div>
</div>
<div className="mt-3"> <div className="col-lg-9">
{data && data.items.length === 0 && <div className="alert alert-warning">No machine types found.</div>} {data && data.items.length === 0 && <div className="alert alert-warning">No machine types found.</div>}
{data && data.items.length > 0 && ( {data && data.items.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
@@ -68,6 +90,7 @@ export default function MachineTypesSearch({ initial, initialQ }: { initial?: Pa
</div> </div>
)} )}
</div> </div>
</div>
<div className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-2">
<span>Page {data?.page ?? 1} / {totalPages}</span> <span>Page {data?.page ?? 1} / {totalPages}</span>

View File

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

View File

@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { listMagazines } from "@/server/repo/zxdb"; import { listMagazines } from "@/server/repo/zxdb";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
export const metadata = { title: "ZXDB Magazines" }; export const metadata = { title: "ZXDB Magazines" };
@@ -19,30 +20,65 @@ export default async function Page({
return ( return (
<div> <div>
<h1 className="mb-3">Magazines</h1> <ZxdbBreadcrumbs
items={[
{ label: "ZXDB", href: "/zxdb" },
{ label: "Magazines" },
]}
/>
<form className="mb-3" action="/zxdb/magazines" method="get"> <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div className="input-group"> <div>
<h1 className="mb-1">Magazines</h1>
<div className="text-secondary">{data.total.toLocaleString()} results</div>
</div>
</div>
<div className="row g-3">
<div className="col-lg-3">
<div className="card shadow-sm">
<div className="card-body">
<form className="d-flex flex-column gap-2" action="/zxdb/magazines" method="get">
<div>
<label className="form-label small text-secondary">Search</label>
<input type="text" className="form-control" name="q" placeholder="Search magazines..." defaultValue={q} /> <input type="text" className="form-control" name="q" placeholder="Search magazines..." defaultValue={q} />
<button className="btn btn-outline-secondary" type="submit"> </div>
<span className="bi bi-search" aria-hidden /> <div className="d-grid">
<span className="visually-hidden">Search</span> <button className="btn btn-primary" type="submit">Search</button>
</button>
</div> </div>
</form> </form>
</div>
</div>
</div>
<div className="list-group"> <div className="col-lg-9">
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th>Title</th>
<th style={{ width: 140 }}>Language</th>
<th style={{ width: 120 }}>Issues</th>
</tr>
</thead>
<tbody>
{data.items.map((m) => ( {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}`}> <tr key={m.id}>
<span> <td>
{m.title} <Link href={`/zxdb/magazines/${m.id}`}>{m.title}</Link>
<span className="text-secondary ms-2">({m.languageId})</span> </td>
</span> <td>{m.languageId}</td>
<td>
<span className="badge bg-secondary rounded-pill" title="Issues"> <span className="badge bg-secondary rounded-pill" title="Issues">
{m.issueCount} {m.issueCount}
</span> </span>
</Link> </td>
</tr>
))} ))}
</tbody>
</table>
</div>
</div>
</div> </div>
<Pagination page={data.page} pageSize={data.pageSize} total={data.total} q={q} /> <Pagination page={data.page} pageSize={data.pageSize} total={data.total} q={q} />

View File

@@ -12,6 +12,22 @@ export default async function Page() {
<h1 className="mb-3">ZXDB Explorer</h1> <h1 className="mb-3">ZXDB Explorer</h1>
<p className="text-secondary">Choose what you want to explore.</p> <p className="text-secondary">Choose what you want to explore.</p>
<form className="row gy-2 gx-2 align-items-center mb-4" method="get" action="/zxdb/entries">
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" name="q" placeholder="Search entries..." />
</div>
<div className="col-auto">
<select className="form-select" name="scope" defaultValue="title">
<option value="title">Titles</option>
<option value="title_aliases">Titles + Aliases</option>
<option value="title_aliases_origins">Titles + Aliases + Origins</option>
</select>
</div>
<div className="col-auto">
<button className="btn btn-primary">Search</button>
</div>
</form>
<div className="row g-3"> <div className="row g-3">
<div className="col-sm-6 col-lg-4"> <div className="col-sm-6 col-lg-4">
<Link href="/zxdb/entries" className="text-decoration-none"> <Link href="/zxdb/entries" className="text-decoration-none">

View File

@@ -4,12 +4,14 @@ import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import EntryLink from "../components/EntryLink"; import EntryLink from "../components/EntryLink";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type Item = { type Item = {
entryId: number; entryId: number;
releaseSeq: number; releaseSeq: number;
entryTitle: string; entryTitle: string;
year: number | null; year: number | null;
magrefCount: number;
}; };
type Paged<T> = { type Paged<T> = {
@@ -229,9 +231,29 @@ export default function ReleasesExplorer({
return ( return (
<div> <div>
<h1 className="mb-3">Releases</h1> <ZxdbBreadcrumbs
<form className="row gy-2 gx-2 align-items-center" onSubmit={onSubmit}> items={[
<div className="col-sm-8 col-md-6 col-lg-4"> { label: "ZXDB", href: "/zxdb" },
{ label: "Releases" },
]}
/>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h1 className="mb-1">Releases</h1>
<div className="text-secondary">
{data ? `${data.total.toLocaleString()} results` : "Loading results..."}
</div>
</div>
</div>
<div className="row g-3">
<div className="col-lg-3">
<div className="card shadow-sm">
<div className="card-body">
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}>
<div>
<label className="form-label small text-secondary">Search title</label>
<input <input
type="text" type="text"
className="form-control" className="form-control"
@@ -240,84 +262,93 @@ export default function ReleasesExplorer({
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
/> />
</div> </div>
<div className="col-auto"> <div className="d-grid">
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button> <button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
</div> </div>
<div className="col-auto"> <div>
<label className="form-label small text-secondary">Year</label>
<input <input
type="number" type="number"
className="form-control" className="form-control"
placeholder="Year" placeholder="Any"
value={year} value={year}
onChange={(e) => { setYear(e.target.value); setPage(1); }} onChange={(e) => { setYear(e.target.value); setPage(1); }}
/> />
</div> </div>
<div className="col-auto"> <div>
<label className="form-label small text-secondary">DL Language</label>
<select className="form-select" value={dLanguageId} onChange={(e) => { setDLanguageId(e.target.value); setPage(1); }}> <select className="form-select" value={dLanguageId} onChange={(e) => { setDLanguageId(e.target.value); setPage(1); }}>
<option value="">DL Language</option> <option value="">All languages</option>
{langs.map((l) => ( {langs.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option> <option key={l.id} value={l.id}>{l.name}</option>
))} ))}
</select> </select>
</div> </div>
<div className="col-auto"> <div>
<label className="form-label small text-secondary">DL Machine</label>
<select className="form-select" value={dMachinetypeId} onChange={(e) => { setDMachinetypeId(e.target.value); setPage(1); }}> <select className="form-select" value={dMachinetypeId} onChange={(e) => { setDMachinetypeId(e.target.value); setPage(1); }}>
<option value="">DL Machine</option> <option value="">All machines</option>
{machines.map((m) => ( {machines.map((m) => (
<option key={m.id} value={m.id}>{m.name}</option> <option key={m.id} value={m.id}>{m.name}</option>
))} ))}
</select> </select>
</div> </div>
<div className="col-auto"> <div>
<label className="form-label small text-secondary">File type</label>
<select className="form-select" value={filetypeId} onChange={(e) => { setFiletypeId(e.target.value); setPage(1); }}> <select className="form-select" value={filetypeId} onChange={(e) => { setFiletypeId(e.target.value); setPage(1); }}>
<option value="">File type</option> <option value="">All file types</option>
{filetypes.map((ft) => ( {filetypes.map((ft) => (
<option key={ft.id} value={ft.id}>{ft.name}</option> <option key={ft.id} value={ft.id}>{ft.name}</option>
))} ))}
</select> </select>
</div> </div>
<div className="col-auto"> <div>
<label className="form-label small text-secondary">Scheme</label>
<select className="form-select" value={schemetypeId} onChange={(e) => { setSchemetypeId(e.target.value); setPage(1); }}> <select className="form-select" value={schemetypeId} onChange={(e) => { setSchemetypeId(e.target.value); setPage(1); }}>
<option value="">Scheme</option> <option value="">All schemes</option>
{schemes.map((s) => ( {schemes.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option> <option key={s.id} value={s.id}>{s.name}</option>
))} ))}
</select> </select>
</div> </div>
<div className="col-auto"> <div>
<label className="form-label small text-secondary">Source</label>
<select className="form-select" value={sourcetypeId} onChange={(e) => { setSourcetypeId(e.target.value); setPage(1); }}> <select className="form-select" value={sourcetypeId} onChange={(e) => { setSourcetypeId(e.target.value); setPage(1); }}>
<option value="">Source</option> <option value="">All sources</option>
{sources.map((s) => ( {sources.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option> <option key={s.id} value={s.id}>{s.name}</option>
))} ))}
</select> </select>
</div> </div>
<div className="col-auto"> <div>
<label className="form-label small text-secondary">Case</label>
<select className="form-select" value={casetypeId} onChange={(e) => { setCasetypeId(e.target.value); setPage(1); }}> <select className="form-select" value={casetypeId} onChange={(e) => { setCasetypeId(e.target.value); setPage(1); }}>
<option value="">Case</option> <option value="">All cases</option>
{cases.map((c) => ( {cases.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option> <option key={c.id} value={c.id}>{c.name}</option>
))} ))}
</select> </select>
</div> </div>
<div className="col-auto form-check ms-2"> <div className="form-check">
<input id="demoCheck" className="form-check-input" type="checkbox" checked={isDemo} onChange={(e) => { setIsDemo(e.target.checked); setPage(1); }} /> <input id="demoCheck" className="form-check-input" type="checkbox" checked={isDemo} onChange={(e) => { setIsDemo(e.target.checked); setPage(1); }} />
<label className="form-check-label" htmlFor="demoCheck">Demo only</label> <label className="form-check-label" htmlFor="demoCheck">Demo only</label>
</div> </div>
<div className="col-auto"> <div>
<label className="form-label small text-secondary">Sort</label>
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as typeof sort); setPage(1); }}> <select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as typeof sort); setPage(1); }}>
<option value="year_desc">Sort: Newest</option> <option value="year_desc">Newest</option>
<option value="year_asc">Sort: Oldest</option> <option value="year_asc">Oldest</option>
<option value="title">Sort: Title</option> <option value="title">Title</option>
<option value="entry_id_desc">Sort: Entry ID</option> <option value="entry_id_desc">Entry ID</option>
</select> </select>
</div> </div>
{loading && ( {loading && <div className="text-secondary small">Loading...</div>}
<div className="col-auto text-secondary">Loading...</div>
)}
</form> </form>
</div>
</div>
</div>
<div className="mt-3"> <div className="col-lg-9">
{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>
)} )}
@@ -326,10 +357,11 @@ export default function ReleasesExplorer({
<table className="table table-striped table-hover align-middle"> <table className="table table-striped table-hover align-middle">
<thead> <thead>
<tr> <tr>
<th style={{width: 80}}>Entry ID</th> <th style={{ width: 80 }}>Entry ID</th>
<th>Title</th> <th>Title</th>
<th style={{width: 140}}>Release #</th> <th style={{ width: 140 }}>Release #</th>
<th style={{width: 100}}>Year</th> <th style={{ width: 110 }}>Places</th>
<th style={{ width: 100 }}>Year</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -339,13 +371,24 @@ export default function ReleasesExplorer({
<EntryLink id={it.entryId} /> <EntryLink id={it.entryId} />
</td> </td>
<td> <td>
<EntryLink id={it.entryId} title={it.entryTitle} /> <div className="d-flex flex-column gap-1">
<Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`} className="link-underline link-underline-opacity-0">
{it.entryTitle || `Entry #${it.entryId}`}
</Link>
</div>
</td> </td>
<td> <td>
<Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`}> <Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`}>
#{it.releaseSeq} #{it.releaseSeq}
</Link> </Link>
</td> </td>
<td>
{it.magrefCount > 0 ? (
<span className="badge text-bg-secondary">{it.magrefCount}</span>
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>{it.year ?? <span className="text-secondary">-</span>}</td> <td>{it.year ?? <span className="text-secondary">-</span>}</td>
</tr> </tr>
))} ))}
@@ -354,11 +397,10 @@ export default function ReleasesExplorer({
</div> </div>
)} )}
</div> </div>
</div>
<div className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-4">
<span> <span>Page {data?.page ?? 1} / {totalPages}</span>
Page {data?.page ?? 1} / {totalPages}
</span>
<div className="ms-auto d-flex gap-2"> <div className="ms-auto d-flex gap-2">
<Link <Link
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`} className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
type ReleaseDetailData = { type ReleaseDetailData = {
entry: { entry: {
@@ -8,6 +9,10 @@ type ReleaseDetailData = {
title: string; title: string;
issueId: number | null; issueId: number | null;
}; };
entryReleases: Array<{
releaseSeq: number;
year: number | null;
}>;
release: { release: {
entryId: number; entryId: number;
releaseSeq: number; releaseSeq: number;
@@ -114,25 +119,70 @@ function formatCurrency(value: number | null, currency: ReleaseDetailData["relea
return String(value); 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 }) { export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData | null }) {
if (!data) return <div className="alert alert-warning">Not found</div>; if (!data) return <div className="alert alert-warning">Not found</div>;
const magazineGroups = groupMagazineRefs(data.magazineRefs);
const otherReleases = data.entryReleases.filter((r) => r.releaseSeq !== data.release.releaseSeq);
return ( return (
<div> <div>
<nav aria-label="breadcrumb"> <ZxdbBreadcrumbs
<ol className="breadcrumb"> items={[
<li className="breadcrumb-item"> { label: "ZXDB", href: "/zxdb" },
<Link href="/zxdb">ZXDB</Link> { label: "Releases", href: "/zxdb/releases" },
</li> { label: data.entry.title, href: `/zxdb/entries/${data.entry.id}` },
<li className="breadcrumb-item"> { label: `Release #${data.release.releaseSeq}` },
<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>
<div className="d-flex align-items-center gap-2 flex-wrap"> <div className="d-flex align-items-center gap-2 flex-wrap">
<h1 className="mb-0">Release #{data.release.releaseSeq}</h1> <h1 className="mb-0">Release #{data.release.releaseSeq}</h1>
@@ -141,29 +191,26 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
</Link> </Link>
</div> </div>
<hr /> <div className="row g-3 mt-2">
<div className="col-lg-4">
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">Release Summary</h5>
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped table-hover align-middle"> <table className="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th style={{ width: 220 }}>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody> <tbody>
<tr> <tr>
<td>Entry</td> <th style={{ width: 160 }}>Entry</th>
<td> <td>
<Link href={`/zxdb/entries/${data.entry.id}`}>#{data.entry.id}</Link> <Link href={`/zxdb/entries/${data.entry.id}`}>#{data.entry.id}</Link>
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Release Sequence</td> <th>Release Sequence</th>
<td>#{data.release.releaseSeq}</td> <td>#{data.release.releaseSeq}</td>
</tr> </tr>
<tr> <tr>
<td>Release Date</td> <th>Release Date</th>
<td> <td>
{data.release.year != null ? ( {data.release.year != null ? (
<span> <span>
@@ -177,7 +224,7 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Currency</td> <th>Currency</th>
<td> <td>
{data.release.currency.id ? ( {data.release.currency.id ? (
<span>{data.release.currency.id} {data.release.currency.name ? `(${data.release.currency.name})` : ""}</span> <span>{data.release.currency.id} {data.release.currency.name ? `(${data.release.currency.name})` : ""}</span>
@@ -187,71 +234,107 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Price</td> <th>Price</th>
<td>{formatCurrency(data.release.prices.release, data.release.currency)}</td> <td>{formatCurrency(data.release.prices.release, data.release.currency)}</td>
</tr> </tr>
<tr> <tr>
<td>Budget Price</td> <th>Budget Price</th>
<td>{formatCurrency(data.release.prices.budget, data.release.currency)}</td> <td>{formatCurrency(data.release.prices.budget, data.release.currency)}</td>
</tr> </tr>
<tr> <tr>
<td>Microdrive Price</td> <th>Microdrive Price</th>
<td>{formatCurrency(data.release.prices.microdrive, data.release.currency)}</td> <td>{formatCurrency(data.release.prices.microdrive, data.release.currency)}</td>
</tr> </tr>
<tr> <tr>
<td>Disk Price</td> <th>Disk Price</th>
<td>{formatCurrency(data.release.prices.disk, data.release.currency)}</td> <td>{formatCurrency(data.release.prices.disk, data.release.currency)}</td>
</tr> </tr>
<tr> <tr>
<td>Cartridge Price</td> <th>Cartridge Price</th>
<td>{formatCurrency(data.release.prices.cartridge, data.release.currency)}</td> <td>{formatCurrency(data.release.prices.cartridge, data.release.currency)}</td>
</tr> </tr>
<tr> <tr>
<td>Book ISBN</td> <th>Book ISBN</th>
<td>{data.release.book.isbn ?? <span className="text-secondary">-</span>}</td> <td>{data.release.book.isbn ?? <span className="text-secondary">-</span>}</td>
</tr> </tr>
<tr> <tr>
<td>Book Pages</td> <th>Book Pages</th>
<td>{data.release.book.pages ?? <span className="text-secondary">-</span>}</td> <td>{data.release.book.pages ?? <span className="text-secondary">-</span>}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</div>
<hr /> <div className="card shadow-sm">
<div className="card-body">
<h5 className="card-title">Other Releases</h5>
{otherReleases.length === 0 && <div className="text-secondary">No other releases</div>}
{otherReleases.length > 0 && (
<div className="d-flex flex-wrap gap-2">
{otherReleases.map((r) => (
<Link
key={r.releaseSeq}
className="badge text-bg-light text-decoration-none"
href={`/zxdb/releases/${data.entry.id}/${r.releaseSeq}`}
>
#{r.releaseSeq}{r.year != null ? ` · ${r.year}` : ""}
</Link>
))}
</div>
)}
</div>
</div>
</div>
<div className="col-lg-8">
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">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> <div>
<h5>Magazine References</h5> <Link href={`/zxdb/issues/${issueGroup.issueId}`}>Issue #{issueGroup.issueId}</Link>
{data.magazineRefs.length === 0 && <div className="text-secondary">No magazine references</div>} <div className="text-secondary small">{formatIssue(issueGroup.issue) || "-"}</div>
{data.magazineRefs.length > 0 && ( </div>
<div className="table-responsive"> <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"> <table className="table table-sm table-striped align-middle">
<thead> <thead>
<tr> <tr>
<th>Magazine</th>
<th>Issue</th>
<th style={{ width: 120 }}>Type</th>
<th style={{ width: 80 }}>Page</th> <th style={{ width: 80 }}>Page</th>
<th style={{ width: 120 }}>Type</th>
<th style={{ width: 100 }}>Original</th> <th style={{ width: 100 }}>Original</th>
<th>Notes</th> <th>Notes</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.magazineRefs.map((m) => ( {issueGroup.items.map((m) => (
<tr key={m.id}> <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.page}</td>
<td>{m.referencetypeName ?? `#${m.referencetypeId}`}</td>
<td>{m.isOriginal ? "Yes" : "No"}</td> <td>{m.isOriginal ? "Yes" : "No"}</td>
<td>{m.scoreGroup || "-"}</td> <td>{m.scoreGroup || "-"}</td>
</tr> </tr>
@@ -259,13 +342,18 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
))}
</div>
))}
</div>
)} )}
</div> </div>
</div>
<hr /> <div className="card shadow-sm mb-3">
<div className="card-body">
<div> <h5 className="card-title">Downloads</h5>
<h5>Downloads</h5>
{data.downloads.length === 0 && <div className="text-secondary">No downloads</div>} {data.downloads.length === 0 && <div className="text-secondary">No downloads</div>}
{data.downloads.length > 0 && ( {data.downloads.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
@@ -324,11 +412,11 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
</div> </div>
)} )}
</div> </div>
</div>
<hr /> <div className="card shadow-sm mb-3">
<div className="card-body">
<div> <h5 className="card-title">Scraps / Media</h5>
<h5>Scraps / Media</h5>
{data.scraps.length === 0 && <div className="text-secondary">No scraps</div>} {data.scraps.length === 0 && <div className="text-secondary">No scraps</div>}
{data.scraps.length > 0 && ( {data.scraps.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
@@ -389,11 +477,11 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
</div> </div>
)} )}
</div> </div>
</div>
<hr /> <div className="card shadow-sm mb-3">
<div className="card-body">
<div> <h5 className="card-title">Issue Files</h5>
<h5>Issue Files</h5>
{data.files.length === 0 && <div className="text-secondary">No files linked</div>} {data.files.length === 0 && <div className="text-secondary">No files linked</div>}
{data.files.length > 0 && ( {data.files.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
@@ -431,8 +519,9 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
</div> </div>
)} )}
</div> </div>
</div>
<hr /> </div>
</div>
<div className="d-flex align-items-center gap-2"> <div className="d-flex align-items-center gap-2">
<Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/releases/${data.entry.id}/${data.release.releaseSeq}`}>Permalink</Link> <Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/releases/${data.entry.id}/${data.release.releaseSeq}`}>Permalink</Link>

View File

@@ -14,7 +14,7 @@ export default function NavbarClient() {
<Nav className="me-auto mb-2 mb-lg-0"> <Nav className="me-auto mb-2 mb-lg-0">
<Link className="nav-link" href="/">Home</Link> <Link className="nav-link" href="/">Home</Link>
<Link className="nav-link" href="/registers">Registers</Link> <Link className="nav-link" href="/registers">Registers</Link>
{/*<Link className="nav-link" href="/zxdb">ZXDB</Link>*/} <Link className="nav-link" href="/zxdb">ZXDB</Link>
</Nav> </Nav>
<ThemeDropdown /> <ThemeDropdown />

File diff suppressed because it is too large Load Diff