Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48d02adbed | |||
| 9bb0a18695 | |||
| 89d48edbd9 | |||
| 0b0dced512 | |||
| e94492eab6 | |||
| 6f7ffa899d | |||
| 84dee2710c | |||
| 5130a72641 | |||
| 964b48abf1 | |||
| d9f55c3eb6 | |||
| 06ddeba9bb | |||
| fb206734db | |||
| e2f6aac856 | |||
| 3e13da5552 | |||
| 0594b34c62 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -45,3 +45,4 @@ next-env.d.ts
|
||||
.pnpm
|
||||
.pnpm-store
|
||||
ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql
|
||||
bin/sync-downloads.mjs
|
||||
|
||||
12
AGENTS.md
12
AGENTS.md
@@ -153,7 +153,17 @@ 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").
|
||||
- Include relevant issue numbers if applicable.
|
||||
- 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
|
||||
|
||||
- ZXDB setup and API usage: `docs/ZXDB.md`
|
||||
- ZXDB setup and API usage: `docs/ZXDB.md`
|
||||
|
||||
@@ -22,6 +22,9 @@ Project scripts (package.json)
|
||||
- `dev`: `PORT=4000 next dev --turbopack`
|
||||
- `build`: `next build --turbopack`
|
||||
- `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-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
|
||||
- `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)
|
||||
- `GET /api/zxdb/search?q=...&page=1&pageSize=20&genreId=...&languageId=...&machinetypeId=...&sort=title&facets=1`
|
||||
- `GET /api/zxdb/entries/[id]`
|
||||
|
||||
23
bin/deploy.sh
Executable file
23
bin/deploy.sh
Executable 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
18
bin/setup-zxdb-local.sh
Executable 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"
|
||||
23
docs/ZXDB.md
23
docs/ZXDB.md
@@ -4,13 +4,14 @@ This document explains how the ZXDB Explorer works in this project, how to set u
|
||||
|
||||
## What is ZXDB?
|
||||
|
||||
ZXDB ( https://github.com/zxdb/ZXDB )is a community‑maintained database of ZX Spectrum software, publications, and related entities. In this project, we connect to a MySQL ZXDB instance in read‑only mode and expose a fast, cross‑linked explorer UI under `/zxdb`.
|
||||
ZXDB (https://github.com/zxdb/ZXDB) is a community‑maintained database of ZX Spectrum software, publications, and related entities. In this project, we connect to a MySQL ZXDB instance in read‑only mode and expose a fast, cross‑linked explorer UI under `/zxdb`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- 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).
|
||||
- A read‑only 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
|
||||
|
||||
@@ -19,7 +20,8 @@ ZXDB ( https://github.com/zxdb/ZXDB )is a community‑maintained database of ZX
|
||||
|
||||
2. Create helper search tables (required).
|
||||
- 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 read‑only role/user (recommended).
|
||||
- Create user `zxdb_readonly`.
|
||||
@@ -48,9 +50,14 @@ pnpm dev
|
||||
## Explorer UI overview
|
||||
|
||||
- `/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/labels` and `/zxdb/labels/[id]` — Browse/search labels (people/companies) and view authored/published entries.
|
||||
- `/zxdb/entries` — Entries search with scope toggles (titles/aliases/origins) and facets.
|
||||
- `/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/magazines` and `/zxdb/magazines/[id]` — Magazine list and issue navigation.
|
||||
- `/zxdb/issues/[id]` — Issue detail with contents and references.
|
||||
|
||||
Cross‑linking: 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)
|
||||
- `genreId`, `languageId`, `machinetypeId` — optional filters
|
||||
- `sort` — `title` or `id_desc`
|
||||
- `scope` — `title`, `title_aliases`, or `title_aliases_origins`
|
||||
- `facets` — boolean; if truthy, includes facet counts for genres/languages/machines
|
||||
|
||||
- Entry detail
|
||||
@@ -99,12 +107,13 @@ Runtime: API routes declare `export const runtime = "nodejs"` to support `mysql2
|
||||
## Troubleshooting
|
||||
|
||||
- 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`.
|
||||
- Slow entry page: confirm server‑rendering 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.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Facet counts displayed in the `/zxdb` filter UI.
|
||||
- Breadcrumbs and additional a11y polish.
|
||||
- Media assets and download links per release (future).
|
||||
- Issue-centric media grouping and richer magazine metadata.
|
||||
- Additional cross-links for tags, relations, and permissions as UI expands.
|
||||
- A11y polish and higher-level navigation enhancements.
|
||||
|
||||
@@ -15,6 +15,12 @@ Run in development
|
||||
- Command: pnpm dev
|
||||
- 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: pnpm build
|
||||
- Start: pnpm start
|
||||
@@ -24,7 +30,9 @@ Lint
|
||||
- pnpm lint
|
||||
|
||||
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-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.
|
||||
|
||||
@@ -5,5 +5,6 @@ Welcome to the Spectrum Next Explorer docs. This site provides an overview of th
|
||||
- Getting Started: ./getting-started.md
|
||||
- Architecture: ./architecture.md
|
||||
- Register Explorer: ./registers.md
|
||||
- ZXDB Explorer: ./ZXDB.md
|
||||
|
||||
If you’re browsing on GitHub, the main README also links to these documents.
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"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-test": "git push --set-upstream test.explorer.specnext.dev test"
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ const querySchema = z.object({
|
||||
.optional(),
|
||||
machinetypeId: z.coerce.number().int().positive().optional(),
|
||||
sort: z.enum(["title", "id_desc"]).optional(),
|
||||
scope: z.enum(["title", "title_aliases", "title_aliases_origins"]).optional(),
|
||||
facets: z.coerce.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -27,6 +28,7 @@ export async function GET(req: NextRequest) {
|
||||
languageId: searchParams.get("languageId") ?? undefined,
|
||||
machinetypeId: searchParams.get("machinetypeId") ?? undefined,
|
||||
sort: searchParams.get("sort") ?? undefined,
|
||||
scope: searchParams.get("scope") ?? undefined,
|
||||
facets: searchParams.get("facets") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
|
||||
27
src/app/zxdb/components/ZxdbBreadcrumbs.tsx
Normal file
27
src/app/zxdb/components/ZxdbBreadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -4,17 +4,22 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import EntryLink from "../components/EntryLink";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||
|
||||
type Item = {
|
||||
id: number;
|
||||
title: string;
|
||||
isXrated: number;
|
||||
genreId: number | null;
|
||||
genreName?: string | null;
|
||||
machinetypeId: number | null;
|
||||
machinetypeName?: string | null;
|
||||
languageId: string | null;
|
||||
languageName?: string | null;
|
||||
};
|
||||
|
||||
type SearchScope = "title" | "title_aliases" | "title_aliases_origins";
|
||||
|
||||
type Paged<T> = {
|
||||
items: T[];
|
||||
page: number;
|
||||
@@ -22,17 +27,26 @@ type Paged<T> = {
|
||||
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({
|
||||
initial,
|
||||
initialGenres,
|
||||
initialLanguages,
|
||||
initialMachines,
|
||||
initialFacets,
|
||||
initialUrlState,
|
||||
}: {
|
||||
initial?: Paged<Item>;
|
||||
initialGenres?: { id: number; name: string }[];
|
||||
initialLanguages?: { id: string; name: string }[];
|
||||
initialMachines?: { id: number; name: string }[];
|
||||
initialFacets?: EntryFacets | null;
|
||||
initialUrlState?: {
|
||||
q: string;
|
||||
page: number;
|
||||
@@ -40,6 +54,7 @@ export default function EntriesExplorer({
|
||||
languageId: string | "";
|
||||
machinetypeId: string | number | "";
|
||||
sort: "title" | "id_desc";
|
||||
scope?: SearchScope;
|
||||
};
|
||||
}) {
|
||||
const router = useRouter();
|
||||
@@ -60,9 +75,30 @@ export default function EntriesExplorer({
|
||||
initialUrlState?.machinetypeId === "" ? "" : initialUrlState?.machinetypeId ? Number(initialUrlState.machinetypeId) : ""
|
||||
);
|
||||
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 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) {
|
||||
const params = new URLSearchParams();
|
||||
@@ -72,11 +108,12 @@ export default function EntriesExplorer({
|
||||
if (languageId !== "") params.set("languageId", String(languageId));
|
||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||
if (sort) params.set("sort", sort);
|
||||
if (scope !== "title") params.set("scope", scope);
|
||||
const qs = params.toString();
|
||||
router.replace(qs ? `${pathname}?${qs}` : pathname);
|
||||
}
|
||||
|
||||
async function fetchData(query: string, p: number) {
|
||||
async function fetchData(query: string, p: number, withFacets: boolean) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
@@ -87,10 +124,15 @@ export default function EntriesExplorer({
|
||||
if (languageId !== "") params.set("languageId", String(languageId));
|
||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||
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()}`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
const json: Paged<Item> = await res.json();
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
if (withFacets && json.facets) {
|
||||
setFacets(json.facets as EntryFacets);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setData({ items: [], page: 1, pageSize, total: 0 });
|
||||
@@ -119,15 +161,16 @@ export default function EntriesExplorer({
|
||||
(initialUrlState?.languageId ?? "") === (languageId ?? "") &&
|
||||
(initialUrlState?.machinetypeId === "" ? "" : Number(initialUrlState?.machinetypeId ?? "")) ===
|
||||
(machinetypeId === "" ? "" : Number(machinetypeId)) &&
|
||||
sort === (initialUrlState?.sort ?? "id_desc")
|
||||
sort === (initialUrlState?.sort ?? "id_desc") &&
|
||||
(initialUrlState?.scope ?? "title") === scope
|
||||
) {
|
||||
updateUrl(page);
|
||||
return;
|
||||
}
|
||||
updateUrl(page);
|
||||
fetchData(q, page);
|
||||
fetchData(q, page, true);
|
||||
// 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
|
||||
useEffect(() => {
|
||||
@@ -151,7 +194,17 @@ export default function EntriesExplorer({
|
||||
e.preventDefault();
|
||||
setPage(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(() => {
|
||||
@@ -162,8 +215,9 @@ export default function EntriesExplorer({
|
||||
if (languageId !== "") params.set("languageId", String(languageId));
|
||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||
if (sort) params.set("sort", sort);
|
||||
if (scope !== "title") params.set("scope", scope);
|
||||
return `/zxdb/entries?${params.toString()}`;
|
||||
}, [q, data?.page, genreId, languageId, machinetypeId, sort]);
|
||||
}, [q, data?.page, genreId, languageId, machinetypeId, sort, scope]);
|
||||
|
||||
const nextHref = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -173,93 +227,172 @@ export default function EntriesExplorer({
|
||||
if (languageId !== "") params.set("languageId", String(languageId));
|
||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||
if (sort) params.set("sort", sort);
|
||||
if (scope !== "title") params.set("scope", scope);
|
||||
return `/zxdb/entries?${params.toString()}`;
|
||||
}, [q, data?.page, genreId, languageId, machinetypeId, sort]);
|
||||
}, [q, data?.page, genreId, languageId, machinetypeId, sort, scope]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-3">Entries</h1>
|
||||
<form className="row gy-2 gx-2 align-items-center" onSubmit={onSubmit}>
|
||||
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Search titles..."
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select className="form-select" value={genreId} onChange={(e) => { setGenreId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
|
||||
<option value="">Genre</option>
|
||||
{genres.map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select className="form-select" value={languageId} onChange={(e) => { setLanguageId(e.target.value); setPage(1); }}>
|
||||
<option value="">Language</option>
|
||||
{languages.map((l) => (
|
||||
<option key={l.id} value={l.id}>{l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select className="form-select" value={machinetypeId} onChange={(e) => { setMachinetypeId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
|
||||
<option value="">Machine</option>
|
||||
{machines.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<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="id_desc">Sort: Newest</option>
|
||||
</select>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="col-auto text-secondary">Loading...</div>
|
||||
)}
|
||||
</form>
|
||||
<ZxdbBreadcrumbs
|
||||
items={[
|
||||
{ label: "ZXDB", href: "/zxdb" },
|
||||
{ label: "Entries" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
{data && data.items.length === 0 && !loading && (
|
||||
<div className="alert alert-warning">No results.</div>
|
||||
<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>
|
||||
)}
|
||||
{data && data.items.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
</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
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Search titles..."
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="d-grid">
|
||||
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
|
||||
</div>
|
||||
<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); }}>
|
||||
<option value="">All genres</option>
|
||||
{genres.map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label small text-secondary">Language</label>
|
||||
<select className="form-select" value={languageId} onChange={(e) => { setLanguageId(e.target.value); setPage(1); }}>
|
||||
<option value="">All languages</option>
|
||||
{languages.map((l) => (
|
||||
<option key={l.id} value={l.id}>{l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<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); }}>
|
||||
<option value="">All machines</option>
|
||||
{machines.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<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); }}>
|
||||
<option value="title">Title (A–Z)</option>
|
||||
<option value="id_desc">Newest</option>
|
||||
</select>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-9">
|
||||
{data && data.items.length === 0 && !loading && (
|
||||
<div className="alert alert-warning">No results.</div>
|
||||
)}
|
||||
{data && data.items.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{width: 80}}>ID</th>
|
||||
<th style={{ width: 80 }}>ID</th>
|
||||
<th>Title</th>
|
||||
<th style={{width: 160}}>Machine</th>
|
||||
<th style={{width: 120}}>Language</th>
|
||||
<th style={{ width: 160 }}>Genre</th>
|
||||
<th style={{ width: 160 }}>Machine</th>
|
||||
<th style={{ width: 120 }}>Language</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((it) => (
|
||||
<tr key={it.id}>
|
||||
<td><EntryLink id={it.id} /></td>
|
||||
<td><EntryLink id={it.id} title={it.title} /></td>
|
||||
<td>
|
||||
<EntryLink id={it.id} title={it.title} />
|
||||
</td>
|
||||
<td>
|
||||
{it.machinetypeId != null ? (
|
||||
it.machinetypeName ? (
|
||||
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
|
||||
{it.genreId != null ? (
|
||||
it.genreName ? (
|
||||
<Link href={`/zxdb/genres/${it.genreId}`}>{it.genreName}</Link>
|
||||
) : (
|
||||
<span>{it.machinetypeId}</span>
|
||||
<span>{it.genreId}</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-secondary">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{it.machinetypeId != null ? (
|
||||
it.machinetypeName ? (
|
||||
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
|
||||
) : (
|
||||
<span>{it.machinetypeId}</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-secondary">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{it.languageId ? (
|
||||
it.languageName ? (
|
||||
@@ -275,14 +408,13 @@ export default function EntriesExplorer({
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center gap-2 mt-2">
|
||||
<span>
|
||||
Page {data?.page ?? 1} / {totalPages}
|
||||
</span>
|
||||
<div className="d-flex align-items-center gap-2 mt-4">
|
||||
<span>Page {data?.page ?? 1} / {totalPages}</span>
|
||||
<div className="ms-auto d-flex gap-2">
|
||||
<Link
|
||||
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
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 = {
|
||||
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 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 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({
|
||||
page,
|
||||
pageSize: 20,
|
||||
sort,
|
||||
q,
|
||||
scope,
|
||||
genreId: genreId ? Number(genreId) : undefined,
|
||||
languageId: languageId || undefined,
|
||||
machinetypeId: machinetypeId ? Number(machinetypeId) : undefined,
|
||||
@@ -29,6 +34,14 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
|
||||
listGenres(),
|
||||
listLanguages(),
|
||||
listMachinetypes(),
|
||||
getEntryFacets({
|
||||
q,
|
||||
sort,
|
||||
scope,
|
||||
genreId: genreId ? Number(genreId) : undefined,
|
||||
languageId: languageId || undefined,
|
||||
machinetypeId: machinetypeId ? Number(machinetypeId) : undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -37,7 +50,8 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
|
||||
initialGenres={genres}
|
||||
initialLanguages={langs}
|
||||
initialMachines={machines}
|
||||
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort }}
|
||||
initialFacets={facets}
|
||||
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort, scope }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||
|
||||
type Genre = { id: number; name: string };
|
||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||
@@ -31,40 +32,62 @@ export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Ge
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Genres</h1>
|
||||
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
|
||||
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||
<input className="form-control" placeholder="Search genres…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button className="btn btn-primary">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
<ZxdbBreadcrumbs
|
||||
items={[
|
||||
{ label: "ZXDB", href: "/zxdb" },
|
||||
{ label: "Genres" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
{data && data.items.length === 0 && <div className="alert alert-warning">No genres found.</div>}
|
||||
{data && data.items.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 120 }}>ID</th>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((g) => (
|
||||
<tr key={g.id}>
|
||||
<td><span className="badge text-bg-light">#{g.id}</span></td>
|
||||
<td>
|
||||
<Link href={`/zxdb/genres/${g.id}`}>{g.name}</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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)} />
|
||||
</div>
|
||||
<div className="d-grid">
|
||||
<button className="btn btn-primary">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 120 }}>ID</th>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((g) => (
|
||||
<tr key={g.id}>
|
||||
<td><span className="badge text-bg-light">#{g.id}</span></td>
|
||||
<td>
|
||||
<Link href={`/zxdb/genres/${g.id}`}>{g.name}</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center gap-2 mt-2">
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getIssue } from "@/server/repo/zxdb";
|
||||
import EntryLink from "@/app/zxdb/components/EntryLink";
|
||||
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||
|
||||
export const metadata = { title: "ZXDB Issue" };
|
||||
export const revalidate = 3600;
|
||||
@@ -18,6 +19,15 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ZxdbBreadcrumbs
|
||||
items={[
|
||||
{ label: "ZXDB", href: "/zxdb" },
|
||||
{ label: "Magazines", href: "/zxdb/magazines" },
|
||||
{ label: issue.magazine.title, href: `/zxdb/magazines/${issue.magazine.id}` },
|
||||
{ label: `Issue ${ym || issue.id}` },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-3 d-flex gap-2 flex-wrap">
|
||||
<Link className="btn btn-outline-secondary btn-sm" href={`/zxdb/magazines/${issue.magazine.id}`}>← Back to magazine</Link>
|
||||
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/magazines">All magazines</Link>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||
|
||||
type Label = { id: number; name: string; labeltypeId: string | null };
|
||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||
@@ -33,44 +34,66 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<La
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Labels</h1>
|
||||
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
|
||||
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||
<input className="form-control" placeholder="Search labels…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button className="btn btn-primary">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
<ZxdbBreadcrumbs
|
||||
items={[
|
||||
{ label: "ZXDB", href: "/zxdb" },
|
||||
{ label: "Labels" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
{data && data.items.length === 0 && <div className="alert alert-warning">No labels found.</div>}
|
||||
{data && data.items.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 100 }}>ID</th>
|
||||
<th>Name</th>
|
||||
<th style={{ width: 120 }}>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td>#{l.id}</td>
|
||||
<td>
|
||||
<Link href={`/zxdb/labels/${l.id}`}>{l.name}</Link>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge text-bg-light">{l.labeltypeId ?? "?"}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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)} />
|
||||
</div>
|
||||
<div className="d-grid">
|
||||
<button className="btn btn-primary">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 100 }}>ID</th>
|
||||
<th>Name</th>
|
||||
<th style={{ width: 120 }}>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td>#{l.id}</td>
|
||||
<td>
|
||||
<Link href={`/zxdb/labels/${l.id}`}>{l.name}</Link>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge text-bg-light">{l.labeltypeId ?? "?"}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center gap-2 mt-2">
|
||||
|
||||
@@ -5,7 +5,25 @@ import EntryLink from "../../components/EntryLink";
|
||||
import { useMemo, useState } from "react";
|
||||
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 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">
|
||||
<h1 className="mb-0">{initial.label.name}</h1>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||
|
||||
type Language = { id: string; name: string };
|
||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||
@@ -31,40 +32,62 @@ export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Languages</h1>
|
||||
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
|
||||
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||
<input className="form-control" placeholder="Search languages…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button className="btn btn-primary">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
<ZxdbBreadcrumbs
|
||||
items={[
|
||||
{ label: "ZXDB", href: "/zxdb" },
|
||||
{ label: "Languages" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
{data && data.items.length === 0 && <div className="alert alert-warning">No languages found.</div>}
|
||||
{data && data.items.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 120 }}>Code</th>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td><span className="badge text-bg-light">{l.id}</span></td>
|
||||
<td>
|
||||
<Link href={`/zxdb/languages/${l.id}`}>{l.name}</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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)} />
|
||||
</div>
|
||||
<div className="d-grid">
|
||||
<button className="btn btn-primary">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 120 }}>Code</th>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td><span className="badge text-bg-light">{l.id}</span></td>
|
||||
<td>
|
||||
<Link href={`/zxdb/languages/${l.id}`}>{l.name}</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center gap-2 mt-2">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||
|
||||
type MT = { id: number; name: string };
|
||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||
@@ -33,40 +34,62 @@ export default function MachineTypesSearch({ initial, initialQ }: { initial?: Pa
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Machine Types</h1>
|
||||
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
|
||||
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||
<input className="form-control" placeholder="Search machine types…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button className="btn btn-primary">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
<ZxdbBreadcrumbs
|
||||
items={[
|
||||
{ label: "ZXDB", href: "/zxdb" },
|
||||
{ label: "Machine Types" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
{data && data.items.length === 0 && <div className="alert alert-warning">No machine types found.</div>}
|
||||
{data && data.items.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 120 }}>ID</th>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td><span className="badge text-bg-light">#{m.id}</span></td>
|
||||
<td>
|
||||
<Link href={`/zxdb/machinetypes/${m.id}`}>{m.name}</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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)} />
|
||||
</div>
|
||||
<div className="d-grid">
|
||||
<button className="btn btn-primary">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 120 }}>ID</th>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td><span className="badge text-bg-light">#{m.id}</span></td>
|
||||
<td>
|
||||
<Link href={`/zxdb/machinetypes/${m.id}`}>{m.name}</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center gap-2 mt-2">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getMagazine } from "@/server/repo/zxdb";
|
||||
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||
|
||||
export const metadata = { title: "ZXDB Magazine" };
|
||||
export const revalidate = 3600;
|
||||
@@ -15,6 +16,14 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ZxdbBreadcrumbs
|
||||
items={[
|
||||
{ label: "ZXDB", href: "/zxdb" },
|
||||
{ label: "Magazines", href: "/zxdb/magazines" },
|
||||
{ label: mag.title },
|
||||
]}
|
||||
/>
|
||||
|
||||
<h1 className="mb-1">{mag.title}</h1>
|
||||
<div className="text-secondary mb-3">Language: {mag.languageId}</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { listMagazines } from "@/server/repo/zxdb";
|
||||
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||
|
||||
export const metadata = { title: "ZXDB Magazines" };
|
||||
|
||||
@@ -19,30 +20,65 @@ export default async function Page({
|
||||
|
||||
return (
|
||||
<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="input-group">
|
||||
<input type="text" className="form-control" name="q" placeholder="Search magazines..." defaultValue={q} />
|
||||
<button className="btn btn-outline-secondary" type="submit">
|
||||
<span className="bi bi-search" aria-hidden />
|
||||
<span className="visually-hidden">Search</span>
|
||||
</button>
|
||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||
<div>
|
||||
<h1 className="mb-1">Magazines</h1>
|
||||
<div className="text-secondary">{data.total.toLocaleString()} results</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="list-group">
|
||||
{data.items.map((m) => (
|
||||
<Link key={m.id} className="list-group-item list-group-item-action d-flex justify-content-between align-items-center" href={`/zxdb/magazines/${m.id}`}>
|
||||
<span>
|
||||
{m.title}
|
||||
<span className="text-secondary ms-2">({m.languageId})</span>
|
||||
</span>
|
||||
<span className="badge bg-secondary rounded-pill" title="Issues">
|
||||
{m.issueCount}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
<div 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} />
|
||||
</div>
|
||||
<div className="d-grid">
|
||||
<button className="btn btn-primary" type="submit">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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) => (
|
||||
<tr key={m.id}>
|
||||
<td>
|
||||
<Link href={`/zxdb/magazines/${m.id}`}>{m.title}</Link>
|
||||
</td>
|
||||
<td>{m.languageId}</td>
|
||||
<td>
|
||||
<span className="badge bg-secondary rounded-pill" title="Issues">
|
||||
{m.issueCount}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination page={data.page} pageSize={data.pageSize} total={data.total} q={q} />
|
||||
|
||||
@@ -12,6 +12,22 @@ export default async function Page() {
|
||||
<h1 className="mb-3">ZXDB Explorer</h1>
|
||||
<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="col-sm-6 col-lg-4">
|
||||
<Link href="/zxdb/entries" className="text-decoration-none">
|
||||
|
||||
@@ -4,12 +4,14 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import EntryLink from "../components/EntryLink";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||
|
||||
type Item = {
|
||||
entryId: number;
|
||||
releaseSeq: number;
|
||||
entryTitle: string;
|
||||
year: number | null;
|
||||
magrefCount: number;
|
||||
};
|
||||
|
||||
type Paged<T> = {
|
||||
@@ -229,136 +231,176 @@ export default function ReleasesExplorer({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-3">Releases</h1>
|
||||
<form className="row gy-2 gx-2 align-items-center" onSubmit={onSubmit}>
|
||||
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Filter by entry title..."
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
placeholder="Year"
|
||||
value={year}
|
||||
onChange={(e) => { setYear(e.target.value); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select className="form-select" value={dLanguageId} onChange={(e) => { setDLanguageId(e.target.value); setPage(1); }}>
|
||||
<option value="">DL Language</option>
|
||||
{langs.map((l) => (
|
||||
<option key={l.id} value={l.id}>{l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select className="form-select" value={dMachinetypeId} onChange={(e) => { setDMachinetypeId(e.target.value); setPage(1); }}>
|
||||
<option value="">DL Machine</option>
|
||||
{machines.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select className="form-select" value={filetypeId} onChange={(e) => { setFiletypeId(e.target.value); setPage(1); }}>
|
||||
<option value="">File type</option>
|
||||
{filetypes.map((ft) => (
|
||||
<option key={ft.id} value={ft.id}>{ft.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select className="form-select" value={schemetypeId} onChange={(e) => { setSchemetypeId(e.target.value); setPage(1); }}>
|
||||
<option value="">Scheme</option>
|
||||
{schemes.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select className="form-select" value={sourcetypeId} onChange={(e) => { setSourcetypeId(e.target.value); setPage(1); }}>
|
||||
<option value="">Source</option>
|
||||
{sources.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select className="form-select" value={casetypeId} onChange={(e) => { setCasetypeId(e.target.value); setPage(1); }}>
|
||||
<option value="">Case</option>
|
||||
{cases.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto form-check ms-2">
|
||||
<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>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<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_asc">Sort: Oldest</option>
|
||||
<option value="title">Sort: Title</option>
|
||||
<option value="entry_id_desc">Sort: Entry ID</option>
|
||||
</select>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="col-auto text-secondary">Loading...</div>
|
||||
)}
|
||||
</form>
|
||||
<ZxdbBreadcrumbs
|
||||
items={[
|
||||
{ label: "ZXDB", href: "/zxdb" },
|
||||
{ label: "Releases" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
{data && data.items.length === 0 && !loading && (
|
||||
<div className="alert alert-warning">No results.</div>
|
||||
)}
|
||||
{data && data.items.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{width: 80}}>Entry ID</th>
|
||||
<th>Title</th>
|
||||
<th style={{width: 140}}>Release #</th>
|
||||
<th style={{width: 100}}>Year</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((it) => (
|
||||
<tr key={`${it.entryId}-${it.releaseSeq}`}>
|
||||
<td>
|
||||
<EntryLink id={it.entryId} />
|
||||
</td>
|
||||
<td>
|
||||
<EntryLink id={it.entryId} title={it.entryTitle} />
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`}>
|
||||
#{it.releaseSeq}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{it.year ?? <span className="text-secondary">-</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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="d-flex align-items-center gap-2 mt-2">
|
||||
<span>
|
||||
Page {data?.page ?? 1} / {totalPages}
|
||||
</span>
|
||||
<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
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Filter by entry title..."
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="d-grid">
|
||||
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label small text-secondary">Year</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
placeholder="Any"
|
||||
value={year}
|
||||
onChange={(e) => { setYear(e.target.value); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
<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); }}>
|
||||
<option value="">All languages</option>
|
||||
{langs.map((l) => (
|
||||
<option key={l.id} value={l.id}>{l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<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); }}>
|
||||
<option value="">All machines</option>
|
||||
{machines.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<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); }}>
|
||||
<option value="">All file types</option>
|
||||
{filetypes.map((ft) => (
|
||||
<option key={ft.id} value={ft.id}>{ft.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label small text-secondary">Scheme</label>
|
||||
<select className="form-select" value={schemetypeId} onChange={(e) => { setSchemetypeId(e.target.value); setPage(1); }}>
|
||||
<option value="">All schemes</option>
|
||||
{schemes.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label small text-secondary">Source</label>
|
||||
<select className="form-select" value={sourcetypeId} onChange={(e) => { setSourcetypeId(e.target.value); setPage(1); }}>
|
||||
<option value="">All sources</option>
|
||||
{sources.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label small text-secondary">Case</label>
|
||||
<select className="form-select" value={casetypeId} onChange={(e) => { setCasetypeId(e.target.value); setPage(1); }}>
|
||||
<option value="">All cases</option>
|
||||
{cases.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<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>
|
||||
</div>
|
||||
<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); }}>
|
||||
<option value="year_desc">Newest</option>
|
||||
<option value="year_asc">Oldest</option>
|
||||
<option value="title">Title</option>
|
||||
<option value="entry_id_desc">Entry ID</option>
|
||||
</select>
|
||||
</div>
|
||||
{loading && <div className="text-secondary small">Loading...</div>}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-9">
|
||||
{data && data.items.length === 0 && !loading && (
|
||||
<div className="alert alert-warning">No results.</div>
|
||||
)}
|
||||
{data && data.items.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 80 }}>Entry ID</th>
|
||||
<th>Title</th>
|
||||
<th style={{ width: 140 }}>Release #</th>
|
||||
<th style={{ width: 110 }}>Places</th>
|
||||
<th style={{ width: 100 }}>Year</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((it) => (
|
||||
<tr key={`${it.entryId}-${it.releaseSeq}`}>
|
||||
<td>
|
||||
<EntryLink id={it.entryId} />
|
||||
</td>
|
||||
<td>
|
||||
<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>
|
||||
<Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`}>
|
||||
#{it.releaseSeq}
|
||||
</Link>
|
||||
</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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center gap-2 mt-4">
|
||||
<span>Page {data?.page ?? 1} / {totalPages}</span>
|
||||
<div className="ms-auto d-flex gap-2">
|
||||
<Link
|
||||
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
|
||||
|
||||
type ReleaseDetailData = {
|
||||
entry: {
|
||||
@@ -8,6 +9,10 @@ type ReleaseDetailData = {
|
||||
title: string;
|
||||
issueId: number | null;
|
||||
};
|
||||
entryReleases: Array<{
|
||||
releaseSeq: number;
|
||||
year: number | null;
|
||||
}>;
|
||||
release: {
|
||||
entryId: number;
|
||||
releaseSeq: number;
|
||||
@@ -114,25 +119,70 @@ function formatCurrency(value: number | null, currency: ReleaseDetailData["relea
|
||||
return String(value);
|
||||
}
|
||||
|
||||
type MagazineGroup = {
|
||||
magazineId: number | null;
|
||||
magazineName: string | null;
|
||||
items: ReleaseDetailData["magazineRefs"];
|
||||
};
|
||||
|
||||
type IssueGroup = {
|
||||
issueId: number;
|
||||
issue: ReleaseDetailData["magazineRefs"][number]["issue"];
|
||||
items: ReleaseDetailData["magazineRefs"];
|
||||
};
|
||||
|
||||
function groupMagazineRefs(refs: ReleaseDetailData["magazineRefs"]) {
|
||||
const groups: MagazineGroup[] = [];
|
||||
const lookup = new Map<string, MagazineGroup>();
|
||||
|
||||
for (const ref of refs) {
|
||||
const key = ref.magazineId != null ? `mag:${ref.magazineId}` : "mag:unknown";
|
||||
let group = lookup.get(key);
|
||||
if (!group) {
|
||||
group = { magazineId: ref.magazineId, magazineName: ref.magazineName, items: [] };
|
||||
lookup.set(key, group);
|
||||
groups.push(group);
|
||||
}
|
||||
group.items.push(ref);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function groupIssueRefs(refs: ReleaseDetailData["magazineRefs"]) {
|
||||
const groups: IssueGroup[] = [];
|
||||
const lookup = new Map<number, IssueGroup>();
|
||||
|
||||
for (const ref of refs) {
|
||||
const key = ref.issueId;
|
||||
let group = lookup.get(key);
|
||||
if (!group) {
|
||||
group = { issueId: ref.issueId, issue: ref.issue, items: [] };
|
||||
lookup.set(key, group);
|
||||
groups.push(group);
|
||||
}
|
||||
group.items.push(ref);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData | null }) {
|
||||
if (!data) return <div className="alert alert-warning">Not found</div>;
|
||||
|
||||
const magazineGroups = groupMagazineRefs(data.magazineRefs);
|
||||
const otherReleases = data.entryReleases.filter((r) => r.releaseSeq !== data.release.releaseSeq);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol className="breadcrumb">
|
||||
<li className="breadcrumb-item">
|
||||
<Link href="/zxdb">ZXDB</Link>
|
||||
</li>
|
||||
<li className="breadcrumb-item">
|
||||
<Link href="/zxdb/releases">Releases</Link>
|
||||
</li>
|
||||
<li className="breadcrumb-item">
|
||||
<Link href={`/zxdb/entries/${data.entry.id}`}>{data.entry.title}</Link>
|
||||
</li>
|
||||
<li className="breadcrumb-item active" aria-current="page">Release #{data.release.releaseSeq}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<ZxdbBreadcrumbs
|
||||
items={[
|
||||
{ label: "ZXDB", href: "/zxdb" },
|
||||
{ label: "Releases", href: "/zxdb/releases" },
|
||||
{ label: data.entry.title, href: `/zxdb/entries/${data.entry.id}` },
|
||||
{ label: `Release #${data.release.releaseSeq}` },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="d-flex align-items-center gap-2 flex-wrap">
|
||||
<h1 className="mb-0">Release #{data.release.releaseSeq}</h1>
|
||||
@@ -141,298 +191,337 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 220 }}>Field</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Entry</td>
|
||||
<td>
|
||||
<Link href={`/zxdb/entries/${data.entry.id}`}>#{data.entry.id}</Link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Release Sequence</td>
|
||||
<td>#{data.release.releaseSeq}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Release Date</td>
|
||||
<td>
|
||||
{data.release.year != null ? (
|
||||
<span>
|
||||
{data.release.year}
|
||||
{data.release.month != null ? `/${String(data.release.month).padStart(2, "0")}` : ""}
|
||||
{data.release.day != null ? `/${String(data.release.day).padStart(2, "0")}` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-secondary">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Currency</td>
|
||||
<td>
|
||||
{data.release.currency.id ? (
|
||||
<span>{data.release.currency.id} {data.release.currency.name ? `(${data.release.currency.name})` : ""}</span>
|
||||
) : (
|
||||
<span className="text-secondary">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Price</td>
|
||||
<td>{formatCurrency(data.release.prices.release, data.release.currency)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Budget Price</td>
|
||||
<td>{formatCurrency(data.release.prices.budget, data.release.currency)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Microdrive Price</td>
|
||||
<td>{formatCurrency(data.release.prices.microdrive, data.release.currency)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Disk Price</td>
|
||||
<td>{formatCurrency(data.release.prices.disk, data.release.currency)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cartridge Price</td>
|
||||
<td>{formatCurrency(data.release.prices.cartridge, data.release.currency)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Book ISBN</td>
|
||||
<td>{data.release.book.isbn ?? <span className="text-secondary">-</span>}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Book Pages</td>
|
||||
<td>{data.release.book.pages ?? <span className="text-secondary">-</span>}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h5>Magazine References</h5>
|
||||
{data.magazineRefs.length === 0 && <div className="text-secondary">No magazine references</div>}
|
||||
{data.magazineRefs.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Magazine</th>
|
||||
<th>Issue</th>
|
||||
<th style={{ width: 120 }}>Type</th>
|
||||
<th style={{ width: 80 }}>Page</th>
|
||||
<th style={{ width: 100 }}>Original</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.magazineRefs.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>
|
||||
{m.magazineId != null ? (
|
||||
<Link href={`/zxdb/magazines/${m.magazineId}`}>{m.magazineName ?? `#${m.magazineId}`}</Link>
|
||||
) : (
|
||||
<span className="text-secondary">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/zxdb/issues/${m.issueId}`}>#{m.issueId}</Link>
|
||||
<div className="text-secondary small">{formatIssue(m.issue) || "-"}</div>
|
||||
</td>
|
||||
<td>{m.referencetypeName ?? `#${m.referencetypeId}`}</td>
|
||||
<td>{m.page}</td>
|
||||
<td>{m.isOriginal ? "Yes" : "No"}</td>
|
||||
<td>{m.scoreGroup || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h5>Downloads</h5>
|
||||
{data.downloads.length === 0 && <div className="text-secondary">No downloads</div>}
|
||||
{data.downloads.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Link</th>
|
||||
<th style={{ width: 120 }} className="text-end">Size</th>
|
||||
<th style={{ width: 240 }}>MD5</th>
|
||||
<th>Flags</th>
|
||||
<th>Details</th>
|
||||
<th>Comments</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.downloads.map((d) => {
|
||||
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://");
|
||||
return (
|
||||
<tr key={d.id}>
|
||||
<td><span className="badge text-bg-secondary">{d.type.name}</span></td>
|
||||
<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">
|
||||
<table className="table table-sm table-striped align-middle mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style={{ width: 160 }}>Entry</th>
|
||||
<td>
|
||||
{isHttp ? (
|
||||
<a href={d.link} target="_blank" rel="noopener noreferrer">{d.link}</a>
|
||||
) : (
|
||||
<span>{d.link}</span>
|
||||
)}
|
||||
<Link href={`/zxdb/entries/${data.entry.id}`}>#{data.entry.id}</Link>
|
||||
</td>
|
||||
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
|
||||
<td><code>{d.md5 ?? "-"}</code></td>
|
||||
<td>
|
||||
<div className="d-flex gap-1 flex-wrap">
|
||||
{d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
|
||||
{d.scheme.name ? <span className="badge text-bg-info">{d.scheme.name}</span> : null}
|
||||
{d.source.name ? <span className="badge text-bg-light border">{d.source.name}</span> : null}
|
||||
{d.case.name ? <span className="badge text-bg-secondary">{d.case.name}</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="d-flex gap-2 flex-wrap align-items-center">
|
||||
{d.language.name ? (
|
||||
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${d.language.id}`}>{d.language.name}</Link>
|
||||
) : null}
|
||||
{d.machinetype.name ? (
|
||||
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${d.machinetype.id}`}>{d.machinetype.name}</Link>
|
||||
) : null}
|
||||
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>{d.comments ?? ""}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h5>Scraps / Media</h5>
|
||||
{data.scraps.length === 0 && <div className="text-secondary">No scraps</div>}
|
||||
{data.scraps.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Link</th>
|
||||
<th style={{ width: 120 }} className="text-end">Size</th>
|
||||
<th>Flags</th>
|
||||
<th>Details</th>
|
||||
<th>Rationale</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.scraps.map((s) => {
|
||||
const isHttp = s.link?.startsWith("http://") || s.link?.startsWith("https://");
|
||||
return (
|
||||
<tr key={s.id}>
|
||||
<td><span className="badge text-bg-secondary">{s.type.name}</span></td>
|
||||
<tr>
|
||||
<th>Release Sequence</th>
|
||||
<td>#{data.release.releaseSeq}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Release Date</th>
|
||||
<td>
|
||||
{s.link ? (
|
||||
isHttp ? (
|
||||
<a href={s.link} target="_blank" rel="noopener noreferrer">{s.link}</a>
|
||||
) : (
|
||||
<span>{s.link}</span>
|
||||
)
|
||||
{data.release.year != null ? (
|
||||
<span>
|
||||
{data.release.year}
|
||||
{data.release.month != null ? `/${String(data.release.month).padStart(2, "0")}` : ""}
|
||||
{data.release.day != null ? `/${String(data.release.day).padStart(2, "0")}` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-secondary">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">{typeof s.size === "number" ? s.size.toLocaleString() : "-"}</td>
|
||||
<td>
|
||||
<div className="d-flex gap-1 flex-wrap">
|
||||
{s.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
|
||||
{s.scheme.name ? <span className="badge text-bg-info">{s.scheme.name}</span> : null}
|
||||
{s.source.name ? <span className="badge text-bg-light border">{s.source.name}</span> : null}
|
||||
{s.case.name ? <span className="badge text-bg-secondary">{s.case.name}</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="d-flex gap-2 flex-wrap align-items-center">
|
||||
{s.language.name ? (
|
||||
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${s.language.id}`}>{s.language.name}</Link>
|
||||
) : null}
|
||||
{s.machinetype.name ? (
|
||||
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${s.machinetype.id}`}>{s.machinetype.name}</Link>
|
||||
) : null}
|
||||
{typeof s.year === "number" ? <span className="badge text-bg-dark">{s.year}</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>{s.rationale}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h5>Issue Files</h5>
|
||||
{data.files.length === 0 && <div className="text-secondary">No files linked</div>}
|
||||
{data.files.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Link</th>
|
||||
<th style={{ width: 120 }} className="text-end">Size</th>
|
||||
<th style={{ width: 240 }}>MD5</th>
|
||||
<th>Comments</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.files.map((f) => {
|
||||
const isHttp = f.link.startsWith("http://") || f.link.startsWith("https://");
|
||||
return (
|
||||
<tr key={f.id}>
|
||||
<td><span className="badge text-bg-secondary">{f.type.name}</span></td>
|
||||
<tr>
|
||||
<th>Currency</th>
|
||||
<td>
|
||||
{isHttp ? (
|
||||
<a href={f.link} target="_blank" rel="noopener noreferrer">{f.link}</a>
|
||||
{data.release.currency.id ? (
|
||||
<span>{data.release.currency.id} {data.release.currency.name ? `(${data.release.currency.name})` : ""}</span>
|
||||
) : (
|
||||
<span>{f.link}</span>
|
||||
<span className="text-secondary">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">{f.size != null ? new Intl.NumberFormat().format(f.size) : "-"}</td>
|
||||
<td><code>{f.md5 ?? "-"}</code></td>
|
||||
<td>{f.comments ?? ""}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<tr>
|
||||
<th>Price</th>
|
||||
<td>{formatCurrency(data.release.prices.release, data.release.currency)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Budget Price</th>
|
||||
<td>{formatCurrency(data.release.prices.budget, data.release.currency)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Microdrive Price</th>
|
||||
<td>{formatCurrency(data.release.prices.microdrive, data.release.currency)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Disk Price</th>
|
||||
<td>{formatCurrency(data.release.prices.disk, data.release.currency)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Cartridge Price</th>
|
||||
<td>{formatCurrency(data.release.prices.cartridge, data.release.currency)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Book ISBN</th>
|
||||
<td>{data.release.book.isbn ?? <span className="text-secondary">-</span>}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Book Pages</th>
|
||||
<td>{data.release.book.pages ?? <span className="text-secondary">-</span>}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</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>
|
||||
<Link href={`/zxdb/issues/${issueGroup.issueId}`}>Issue #{issueGroup.issueId}</Link>
|
||||
<div className="text-secondary small">{formatIssue(issueGroup.issue) || "-"}</div>
|
||||
</div>
|
||||
<div className="text-secondary small">
|
||||
{issueGroup.items.length} reference{issueGroup.items.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive mt-2">
|
||||
<table className="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 80 }}>Page</th>
|
||||
<th style={{ width: 120 }}>Type</th>
|
||||
<th style={{ width: 100 }}>Original</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{issueGroup.items.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>{m.page}</td>
|
||||
<td>{m.referencetypeName ?? `#${m.referencetypeId}`}</td>
|
||||
<td>{m.isOriginal ? "Yes" : "No"}</td>
|
||||
<td>{m.scoreGroup || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card shadow-sm mb-3">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Downloads</h5>
|
||||
{data.downloads.length === 0 && <div className="text-secondary">No downloads</div>}
|
||||
{data.downloads.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Link</th>
|
||||
<th style={{ width: 120 }} className="text-end">Size</th>
|
||||
<th style={{ width: 240 }}>MD5</th>
|
||||
<th>Flags</th>
|
||||
<th>Details</th>
|
||||
<th>Comments</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.downloads.map((d) => {
|
||||
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://");
|
||||
return (
|
||||
<tr key={d.id}>
|
||||
<td><span className="badge text-bg-secondary">{d.type.name}</span></td>
|
||||
<td>
|
||||
{isHttp ? (
|
||||
<a href={d.link} target="_blank" rel="noopener noreferrer">{d.link}</a>
|
||||
) : (
|
||||
<span>{d.link}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
|
||||
<td><code>{d.md5 ?? "-"}</code></td>
|
||||
<td>
|
||||
<div className="d-flex gap-1 flex-wrap">
|
||||
{d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
|
||||
{d.scheme.name ? <span className="badge text-bg-info">{d.scheme.name}</span> : null}
|
||||
{d.source.name ? <span className="badge text-bg-light border">{d.source.name}</span> : null}
|
||||
{d.case.name ? <span className="badge text-bg-secondary">{d.case.name}</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="d-flex gap-2 flex-wrap align-items-center">
|
||||
{d.language.name ? (
|
||||
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${d.language.id}`}>{d.language.name}</Link>
|
||||
) : null}
|
||||
{d.machinetype.name ? (
|
||||
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${d.machinetype.id}`}>{d.machinetype.name}</Link>
|
||||
) : null}
|
||||
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>{d.comments ?? ""}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card shadow-sm mb-3">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Scraps / Media</h5>
|
||||
{data.scraps.length === 0 && <div className="text-secondary">No scraps</div>}
|
||||
{data.scraps.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Link</th>
|
||||
<th style={{ width: 120 }} className="text-end">Size</th>
|
||||
<th>Flags</th>
|
||||
<th>Details</th>
|
||||
<th>Rationale</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.scraps.map((s) => {
|
||||
const isHttp = s.link?.startsWith("http://") || s.link?.startsWith("https://");
|
||||
return (
|
||||
<tr key={s.id}>
|
||||
<td><span className="badge text-bg-secondary">{s.type.name}</span></td>
|
||||
<td>
|
||||
{s.link ? (
|
||||
isHttp ? (
|
||||
<a href={s.link} target="_blank" rel="noopener noreferrer">{s.link}</a>
|
||||
) : (
|
||||
<span>{s.link}</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-secondary">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">{typeof s.size === "number" ? s.size.toLocaleString() : "-"}</td>
|
||||
<td>
|
||||
<div className="d-flex gap-1 flex-wrap">
|
||||
{s.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
|
||||
{s.scheme.name ? <span className="badge text-bg-info">{s.scheme.name}</span> : null}
|
||||
{s.source.name ? <span className="badge text-bg-light border">{s.source.name}</span> : null}
|
||||
{s.case.name ? <span className="badge text-bg-secondary">{s.case.name}</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="d-flex gap-2 flex-wrap align-items-center">
|
||||
{s.language.name ? (
|
||||
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${s.language.id}`}>{s.language.name}</Link>
|
||||
) : null}
|
||||
{s.machinetype.name ? (
|
||||
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${s.machinetype.id}`}>{s.machinetype.name}</Link>
|
||||
) : null}
|
||||
{typeof s.year === "number" ? <span className="badge text-bg-dark">{s.year}</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>{s.rationale}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card shadow-sm mb-3">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Issue Files</h5>
|
||||
{data.files.length === 0 && <div className="text-secondary">No files linked</div>}
|
||||
{data.files.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Link</th>
|
||||
<th style={{ width: 120 }} className="text-end">Size</th>
|
||||
<th style={{ width: 240 }}>MD5</th>
|
||||
<th>Comments</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.files.map((f) => {
|
||||
const isHttp = f.link.startsWith("http://") || f.link.startsWith("https://");
|
||||
return (
|
||||
<tr key={f.id}>
|
||||
<td><span className="badge text-bg-secondary">{f.type.name}</span></td>
|
||||
<td>
|
||||
{isHttp ? (
|
||||
<a href={f.link} target="_blank" rel="noopener noreferrer">{f.link}</a>
|
||||
) : (
|
||||
<span>{f.link}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">{f.size != null ? new Intl.NumberFormat().format(f.size) : "-"}</td>
|
||||
<td><code>{f.md5 ?? "-"}</code></td>
|
||||
<td>{f.comments ?? ""}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function NavbarClient() {
|
||||
<Nav className="me-auto mb-2 mb-lg-0">
|
||||
<Link className="nav-link" href="/">Home</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>
|
||||
|
||||
<ThemeDropdown />
|
||||
@@ -22,4 +22,4 @@ export default function NavbarClient() {
|
||||
</Container>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user