Expand ZXDB entry data and search
Add entry release/license sections, label permissions/licenses, expanded search scope (titles+aliases+origins), and home search. Also include ZXDB submodule and update gitignore. Signed-off-by: codex@lucy.xalior.com
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -45,3 +45,4 @@ next-env.d.ts
|
|||||||
.pnpm
|
.pnpm
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql
|
ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql
|
||||||
|
bin/sync-downloads.mjs
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const querySchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
machinetypeId: z.coerce.number().int().positive().optional(),
|
machinetypeId: z.coerce.number().int().positive().optional(),
|
||||||
sort: z.enum(["title", "id_desc"]).optional(),
|
sort: z.enum(["title", "id_desc"]).optional(),
|
||||||
|
scope: z.enum(["title", "title_aliases", "title_aliases_origins"]).optional(),
|
||||||
facets: z.coerce.boolean().optional(),
|
facets: z.coerce.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export async function GET(req: NextRequest) {
|
|||||||
languageId: searchParams.get("languageId") ?? undefined,
|
languageId: searchParams.get("languageId") ?? undefined,
|
||||||
machinetypeId: searchParams.get("machinetypeId") ?? undefined,
|
machinetypeId: searchParams.get("machinetypeId") ?? undefined,
|
||||||
sort: searchParams.get("sort") ?? undefined,
|
sort: searchParams.get("sort") ?? undefined,
|
||||||
|
scope: searchParams.get("scope") ?? undefined,
|
||||||
facets: searchParams.get("facets") ?? undefined,
|
facets: searchParams.get("facets") ?? undefined,
|
||||||
});
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ type Item = {
|
|||||||
languageName?: string | null;
|
languageName?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SearchScope = "title" | "title_aliases" | "title_aliases_origins";
|
||||||
|
|
||||||
type Paged<T> = {
|
type Paged<T> = {
|
||||||
items: T[];
|
items: T[];
|
||||||
page: number;
|
page: number;
|
||||||
@@ -41,6 +43,7 @@ export default function EntriesExplorer({
|
|||||||
languageId: string | "";
|
languageId: string | "";
|
||||||
machinetypeId: string | number | "";
|
machinetypeId: string | number | "";
|
||||||
sort: "title" | "id_desc";
|
sort: "title" | "id_desc";
|
||||||
|
scope?: SearchScope;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -61,6 +64,7 @@ export default function EntriesExplorer({
|
|||||||
initialUrlState?.machinetypeId === "" ? "" : initialUrlState?.machinetypeId ? Number(initialUrlState.machinetypeId) : ""
|
initialUrlState?.machinetypeId === "" ? "" : initialUrlState?.machinetypeId ? Number(initialUrlState.machinetypeId) : ""
|
||||||
);
|
);
|
||||||
const [sort, setSort] = useState<"title" | "id_desc">(initialUrlState?.sort ?? "id_desc");
|
const [sort, setSort] = useState<"title" | "id_desc">(initialUrlState?.sort ?? "id_desc");
|
||||||
|
const [scope, setScope] = useState<SearchScope>(initialUrlState?.scope ?? "title");
|
||||||
|
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||||
@@ -73,6 +77,7 @@ export default function EntriesExplorer({
|
|||||||
if (languageId !== "") params.set("languageId", String(languageId));
|
if (languageId !== "") params.set("languageId", String(languageId));
|
||||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||||
if (sort) params.set("sort", sort);
|
if (sort) params.set("sort", sort);
|
||||||
|
if (scope !== "title") params.set("scope", scope);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
router.replace(qs ? `${pathname}?${qs}` : pathname);
|
router.replace(qs ? `${pathname}?${qs}` : pathname);
|
||||||
}
|
}
|
||||||
@@ -88,6 +93,7 @@ export default function EntriesExplorer({
|
|||||||
if (languageId !== "") params.set("languageId", String(languageId));
|
if (languageId !== "") params.set("languageId", String(languageId));
|
||||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||||
if (sort) params.set("sort", sort);
|
if (sort) params.set("sort", sort);
|
||||||
|
if (scope !== "title") params.set("scope", scope);
|
||||||
const res = await fetch(`/api/zxdb/search?${params.toString()}`);
|
const res = await fetch(`/api/zxdb/search?${params.toString()}`);
|
||||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||||
const json: Paged<Item> = await res.json();
|
const json: Paged<Item> = await res.json();
|
||||||
@@ -120,7 +126,8 @@ export default function EntriesExplorer({
|
|||||||
(initialUrlState?.languageId ?? "") === (languageId ?? "") &&
|
(initialUrlState?.languageId ?? "") === (languageId ?? "") &&
|
||||||
(initialUrlState?.machinetypeId === "" ? "" : Number(initialUrlState?.machinetypeId ?? "")) ===
|
(initialUrlState?.machinetypeId === "" ? "" : Number(initialUrlState?.machinetypeId ?? "")) ===
|
||||||
(machinetypeId === "" ? "" : Number(machinetypeId)) &&
|
(machinetypeId === "" ? "" : Number(machinetypeId)) &&
|
||||||
sort === (initialUrlState?.sort ?? "id_desc")
|
sort === (initialUrlState?.sort ?? "id_desc") &&
|
||||||
|
(initialUrlState?.scope ?? "title") === scope
|
||||||
) {
|
) {
|
||||||
updateUrl(page);
|
updateUrl(page);
|
||||||
return;
|
return;
|
||||||
@@ -128,7 +135,7 @@ export default function EntriesExplorer({
|
|||||||
updateUrl(page);
|
updateUrl(page);
|
||||||
fetchData(q, page);
|
fetchData(q, page);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [page, genreId, languageId, machinetypeId, sort]);
|
}, [page, genreId, languageId, machinetypeId, sort, scope]);
|
||||||
|
|
||||||
// Load filter lists on mount only if not provided by server
|
// Load filter lists on mount only if not provided by server
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -163,8 +170,9 @@ export default function EntriesExplorer({
|
|||||||
if (languageId !== "") params.set("languageId", String(languageId));
|
if (languageId !== "") params.set("languageId", String(languageId));
|
||||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||||
if (sort) params.set("sort", sort);
|
if (sort) params.set("sort", sort);
|
||||||
|
if (scope !== "title") params.set("scope", scope);
|
||||||
return `/zxdb/entries?${params.toString()}`;
|
return `/zxdb/entries?${params.toString()}`;
|
||||||
}, [q, data?.page, genreId, languageId, machinetypeId, sort]);
|
}, [q, data?.page, genreId, languageId, machinetypeId, sort, scope]);
|
||||||
|
|
||||||
const nextHref = useMemo(() => {
|
const nextHref = useMemo(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -174,8 +182,9 @@ export default function EntriesExplorer({
|
|||||||
if (languageId !== "") params.set("languageId", String(languageId));
|
if (languageId !== "") params.set("languageId", String(languageId));
|
||||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||||
if (sort) params.set("sort", sort);
|
if (sort) params.set("sort", sort);
|
||||||
|
if (scope !== "title") params.set("scope", scope);
|
||||||
return `/zxdb/entries?${params.toString()}`;
|
return `/zxdb/entries?${params.toString()}`;
|
||||||
}, [q, data?.page, genreId, languageId, machinetypeId, sort]);
|
}, [q, data?.page, genreId, languageId, machinetypeId, sort, scope]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -230,6 +239,13 @@ export default function EntriesExplorer({
|
|||||||
<option value="id_desc">Sort: Newest</option>
|
<option value="id_desc">Sort: Newest</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select className="form-select" value={scope} onChange={(e) => { setScope(e.target.value as SearchScope); setPage(1); }}>
|
||||||
|
<option value="title">Search: Titles</option>
|
||||||
|
<option value="title_aliases">Search: Titles + Aliases</option>
|
||||||
|
<option value="title_aliases_origins">Search: Titles + Aliases + Origins</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="col-auto text-secondary">Loading...</div>
|
<div className="col-auto text-secondary">Loading...</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ export type EntryDetailData = {
|
|||||||
genre: { id: number | null; name: string | null };
|
genre: { id: number | null; name: string | null };
|
||||||
authors: Label[];
|
authors: Label[];
|
||||||
publishers: Label[];
|
publishers: Label[];
|
||||||
|
licenses?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: { id: string; name: string | null };
|
||||||
|
isOfficial: boolean;
|
||||||
|
linkWikipedia?: string | null;
|
||||||
|
linkSite?: string | null;
|
||||||
|
comments?: string | null;
|
||||||
|
}[];
|
||||||
// extra fields for richer details
|
// extra fields for richer details
|
||||||
maxPlayers?: number;
|
maxPlayers?: number;
|
||||||
availabletypeId?: string | null;
|
availabletypeId?: string | null;
|
||||||
@@ -300,6 +309,37 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
|
|||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h5>Releases</h5>
|
||||||
|
{(!data.releases || data.releases.length === 0) && <div className="text-secondary">No releases recorded</div>}
|
||||||
|
{data.releases && data.releases.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 120 }}>Release #</th>
|
||||||
|
<th style={{ width: 120 }}>Year</th>
|
||||||
|
<th>Downloads</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.releases.map((r) => (
|
||||||
|
<tr key={r.releaseSeq}>
|
||||||
|
<td>
|
||||||
|
<Link href={`/zxdb/releases/${data.id}/${r.releaseSeq}`}>#{r.releaseSeq}</Link>
|
||||||
|
</td>
|
||||||
|
<td>{r.year ?? <span className="text-secondary">-</span>}</td>
|
||||||
|
<td>{r.downloads.length}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
{/* Aliases (alternative titles) */}
|
{/* Aliases (alternative titles) */}
|
||||||
<div>
|
<div>
|
||||||
<h5>Aliases</h5>
|
<h5>Aliases</h5>
|
||||||
@@ -332,6 +372,47 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
|
|||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h5>Licenses</h5>
|
||||||
|
{(!data.licenses || data.licenses.length === 0) && <div className="text-secondary">No licenses linked</div>}
|
||||||
|
{data.licenses && data.licenses.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th style={{ width: 140 }}>Type</th>
|
||||||
|
<th style={{ width: 120 }}>Official</th>
|
||||||
|
<th>Links</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.licenses.map((l) => (
|
||||||
|
<tr key={l.id}>
|
||||||
|
<td>{l.name}</td>
|
||||||
|
<td>{l.type.name ?? l.type.id}</td>
|
||||||
|
<td>{l.isOfficial ? "Yes" : "No"}</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex gap-2 flex-wrap">
|
||||||
|
{l.linkWikipedia && (
|
||||||
|
<a href={l.linkWikipedia} target="_blank" rel="noreferrer">Wikipedia</a>
|
||||||
|
)}
|
||||||
|
{l.linkSite && (
|
||||||
|
<a href={l.linkSite} target="_blank" rel="noreferrer">Site</a>
|
||||||
|
)}
|
||||||
|
{!l.linkWikipedia && !l.linkSite && <span className="text-secondary">-</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
{/* Web links (external references) */}
|
{/* Web links (external references) */}
|
||||||
<div>
|
<div>
|
||||||
<h5>Web links</h5>
|
<h5>Web links</h5>
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
|
|||||||
const machinetypeId = (Array.isArray(sp.machinetypeId) ? sp.machinetypeId[0] : sp.machinetypeId) ?? "";
|
const machinetypeId = (Array.isArray(sp.machinetypeId) ? sp.machinetypeId[0] : sp.machinetypeId) ?? "";
|
||||||
const sort = ((Array.isArray(sp.sort) ? sp.sort[0] : sp.sort) ?? "id_desc") as "title" | "id_desc";
|
const sort = ((Array.isArray(sp.sort) ? sp.sort[0] : sp.sort) ?? "id_desc") as "title" | "id_desc";
|
||||||
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
|
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
|
||||||
|
const scope = ((Array.isArray(sp.scope) ? sp.scope[0] : sp.scope) ?? "title") as
|
||||||
|
| "title"
|
||||||
|
| "title_aliases"
|
||||||
|
| "title_aliases_origins";
|
||||||
|
|
||||||
const [initial, genres, langs, machines] = await Promise.all([
|
const [initial, genres, langs, machines] = await Promise.all([
|
||||||
searchEntries({
|
searchEntries({
|
||||||
@@ -22,6 +26,7 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
|
|||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
sort,
|
sort,
|
||||||
q,
|
q,
|
||||||
|
scope,
|
||||||
genreId: genreId ? Number(genreId) : undefined,
|
genreId: genreId ? Number(genreId) : undefined,
|
||||||
languageId: languageId || undefined,
|
languageId: languageId || undefined,
|
||||||
machinetypeId: machinetypeId ? Number(machinetypeId) : undefined,
|
machinetypeId: machinetypeId ? Number(machinetypeId) : undefined,
|
||||||
@@ -37,7 +42,7 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
|
|||||||
initialGenres={genres}
|
initialGenres={genres}
|
||||||
initialLanguages={langs}
|
initialLanguages={langs}
|
||||||
initialMachines={machines}
|
initialMachines={machines}
|
||||||
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort }}
|
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort, scope }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,24 @@ import EntryLink from "../../components/EntryLink";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
type Label = { id: number; name: string; labeltypeId: string | null };
|
type Label = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
labeltypeId: string | null;
|
||||||
|
permissions: {
|
||||||
|
website: { id: number; name: string; link?: string | null };
|
||||||
|
type: { id: string; name: string | null };
|
||||||
|
text: string | null;
|
||||||
|
}[];
|
||||||
|
licenses: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: { id: string; name: string | null };
|
||||||
|
linkWikipedia?: string | null;
|
||||||
|
linkSite?: string | null;
|
||||||
|
comments?: string | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null };
|
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null };
|
||||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||||
|
|
||||||
@@ -36,6 +53,77 @@ export default function LabelDetailClient({ id, initial, initialTab, initialQ }:
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<ul className="nav nav-tabs mt-3">
|
<ul className="nav nav-tabs mt-3">
|
||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
<button className={`nav-link ${tab === "authored" ? "active" : ""}`} onClick={() => setTab("authored")}>Authored</button>
|
<button className={`nav-link ${tab === "authored" ? "active" : ""}`} onClick={() => setTab("authored")}>Authored</button>
|
||||||
|
|||||||
@@ -12,6 +12,22 @@ export default async function Page() {
|
|||||||
<h1 className="mb-3">ZXDB Explorer</h1>
|
<h1 className="mb-3">ZXDB Explorer</h1>
|
||||||
<p className="text-secondary">Choose what you want to explore.</p>
|
<p className="text-secondary">Choose what you want to explore.</p>
|
||||||
|
|
||||||
|
<form className="row gy-2 gx-2 align-items-center mb-4" method="get" action="/zxdb/entries">
|
||||||
|
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||||
|
<input className="form-control" name="q" placeholder="Search entries..." />
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<select className="form-select" name="scope" defaultValue="title">
|
||||||
|
<option value="title">Titles</option>
|
||||||
|
<option value="title_aliases">Titles + Aliases</option>
|
||||||
|
<option value="title_aliases_origins">Titles + Aliases + Origins</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button className="btn btn-primary">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
<div className="col-sm-6 col-lg-4">
|
<div className="col-sm-6 col-lg-4">
|
||||||
<Link href="/zxdb/entries" className="text-decoration-none">
|
<Link href="/zxdb/entries" className="text-decoration-none">
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { db } from "@/server/db";
|
|||||||
import {
|
import {
|
||||||
entries,
|
entries,
|
||||||
searchByTitles,
|
searchByTitles,
|
||||||
|
searchByAliases,
|
||||||
|
searchByOrigins,
|
||||||
labels,
|
labels,
|
||||||
authors,
|
authors,
|
||||||
publishers,
|
publishers,
|
||||||
@@ -23,6 +25,12 @@ import {
|
|||||||
currencies,
|
currencies,
|
||||||
roletypes,
|
roletypes,
|
||||||
aliases,
|
aliases,
|
||||||
|
relatedlicenses,
|
||||||
|
licenses,
|
||||||
|
licensetypes,
|
||||||
|
licensors,
|
||||||
|
permissions,
|
||||||
|
permissiontypes,
|
||||||
webrefs,
|
webrefs,
|
||||||
websites,
|
websites,
|
||||||
magazines,
|
magazines,
|
||||||
@@ -32,6 +40,8 @@ import {
|
|||||||
referencetypes,
|
referencetypes,
|
||||||
} from "@/server/schema/zxdb";
|
} from "@/server/schema/zxdb";
|
||||||
|
|
||||||
|
export type EntrySearchScope = "title" | "title_aliases" | "title_aliases_origins";
|
||||||
|
|
||||||
export interface SearchParams {
|
export interface SearchParams {
|
||||||
q?: string;
|
q?: string;
|
||||||
page?: number; // 1-based
|
page?: number; // 1-based
|
||||||
@@ -42,6 +52,8 @@ export interface SearchParams {
|
|||||||
machinetypeId?: number;
|
machinetypeId?: number;
|
||||||
// Sorting
|
// Sorting
|
||||||
sort?: "title" | "id_desc";
|
sort?: "title" | "id_desc";
|
||||||
|
// Search scope (defaults to titles only)
|
||||||
|
scope?: EntrySearchScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchResultItem {
|
export interface SearchResultItem {
|
||||||
@@ -73,6 +85,19 @@ export interface EntryFacets {
|
|||||||
machinetypes: FacetItem<number>[];
|
machinetypes: FacetItem<number>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildEntrySearchUnion(pattern: string, scope: EntrySearchScope) {
|
||||||
|
const parts: Array<ReturnType<typeof sql>> = [
|
||||||
|
sql`select ${searchByTitles.entryId} as entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern}`,
|
||||||
|
];
|
||||||
|
if (scope !== "title") {
|
||||||
|
parts.push(sql`select ${searchByAliases.entryId} as entry_id from ${searchByAliases} where ${searchByAliases.libraryTitle} like ${pattern}`);
|
||||||
|
}
|
||||||
|
if (scope === "title_aliases_origins") {
|
||||||
|
parts.push(sql`select ${searchByOrigins.entryId} as entry_id from ${searchByOrigins} where ${searchByOrigins.libraryTitle} like ${pattern}`);
|
||||||
|
}
|
||||||
|
return sql.join(parts, sql` union `);
|
||||||
|
}
|
||||||
|
|
||||||
export interface MagazineListItem {
|
export interface MagazineListItem {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -131,6 +156,7 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
|
|||||||
const page = Math.max(1, params.page ?? 1);
|
const page = Math.max(1, params.page ?? 1);
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
const sort = params.sort ?? (q ? "title" : "id_desc");
|
const sort = params.sort ?? (q ? "title" : "id_desc");
|
||||||
|
const scope: EntrySearchScope = params.scope ?? "title";
|
||||||
|
|
||||||
if (q.length === 0) {
|
if (q.length === 0) {
|
||||||
// Default listing: return first page by id desc (no guaranteed ordering field; using id)
|
// Default listing: return first page by id desc (no guaranteed ordering field; using id)
|
||||||
@@ -180,6 +206,41 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
|
|||||||
|
|
||||||
const pattern = `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
|
const pattern = `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
|
||||||
|
|
||||||
|
if (scope !== "title") {
|
||||||
|
try {
|
||||||
|
const union = buildEntrySearchUnion(pattern, scope);
|
||||||
|
const countRows = await db.execute(sql`
|
||||||
|
select count(distinct entry_id) as total
|
||||||
|
from (${union}) as matches
|
||||||
|
`);
|
||||||
|
type CountRow = { total: number | string };
|
||||||
|
const total = Number((countRows as unknown as CountRow[])[0]?.total ?? 0);
|
||||||
|
|
||||||
|
const items = await db
|
||||||
|
.select({
|
||||||
|
id: entries.id,
|
||||||
|
title: entries.title,
|
||||||
|
isXrated: entries.isXrated,
|
||||||
|
machinetypeId: entries.machinetypeId,
|
||||||
|
machinetypeName: machinetypes.name,
|
||||||
|
languageId: entries.languageId,
|
||||||
|
languageName: languages.name,
|
||||||
|
})
|
||||||
|
.from(entries)
|
||||||
|
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
|
||||||
|
.leftJoin(languages, eq(languages.id, entries.languageId))
|
||||||
|
.where(sql`${entries.id} in (select entry_id from (${union}) as matches)`)
|
||||||
|
.groupBy(entries.id)
|
||||||
|
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return { items, page, pageSize, total };
|
||||||
|
} catch {
|
||||||
|
// Fall through to title-only search if helper tables are unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Count matches via helper table
|
// Count matches via helper table
|
||||||
const countRows = await db
|
const countRows = await db
|
||||||
.select({ total: sql<number>`count(distinct ${searchByTitles.entryId})` })
|
.select({ total: sql<number>`count(distinct ${searchByTitles.entryId})` })
|
||||||
@@ -227,6 +288,15 @@ export interface EntryDetail {
|
|||||||
genre: { id: number | null; name: string | null };
|
genre: { id: number | null; name: string | null };
|
||||||
authors: LabelSummary[];
|
authors: LabelSummary[];
|
||||||
publishers: LabelSummary[];
|
publishers: LabelSummary[];
|
||||||
|
licenses?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: { id: string; name: string | null };
|
||||||
|
isOfficial: boolean;
|
||||||
|
linkWikipedia?: string | null;
|
||||||
|
linkSite?: string | null;
|
||||||
|
comments?: string | null;
|
||||||
|
}[];
|
||||||
// Additional entry fields for richer details
|
// Additional entry fields for richer details
|
||||||
maxPlayers?: number;
|
maxPlayers?: number;
|
||||||
availabletypeId?: string | null;
|
availabletypeId?: string | null;
|
||||||
@@ -479,6 +549,16 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
|
|||||||
// Fetch extra relationships in parallel
|
// Fetch extra relationships in parallel
|
||||||
let aliasRows: { releaseSeq: number | string; languageId: string; title: string }[] = [];
|
let aliasRows: { releaseSeq: number | string; languageId: string; title: string }[] = [];
|
||||||
let webrefRows: { link: string; languageId: string; websiteId: number | string; websiteName: string; websiteLink: string | null }[] = [];
|
let webrefRows: { link: string; languageId: string; websiteId: number | string; websiteName: string; websiteLink: string | null }[] = [];
|
||||||
|
let licenseRows: {
|
||||||
|
id: number | string;
|
||||||
|
name: string;
|
||||||
|
licensetypeId: string;
|
||||||
|
licensetypeName: string | null;
|
||||||
|
isOfficial: number | boolean;
|
||||||
|
linkWikipedia: string | null;
|
||||||
|
linkSite: string | null;
|
||||||
|
comments: string | null;
|
||||||
|
}[] = [];
|
||||||
try {
|
try {
|
||||||
aliasRows = await db
|
aliasRows = await db
|
||||||
.select({ releaseSeq: aliases.releaseSeq, languageId: aliases.languageId, title: aliases.title })
|
.select({ releaseSeq: aliases.releaseSeq, languageId: aliases.languageId, title: aliases.title })
|
||||||
@@ -493,6 +573,24 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
|
|||||||
.where(eq(webrefs.entryId, id));
|
.where(eq(webrefs.entryId, id));
|
||||||
webrefRows = rows as typeof webrefRows;
|
webrefRows = rows as typeof webrefRows;
|
||||||
} catch {}
|
} catch {}
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: licenses.id,
|
||||||
|
name: licenses.name,
|
||||||
|
licensetypeId: licenses.licensetypeId,
|
||||||
|
licensetypeName: licensetypes.name,
|
||||||
|
isOfficial: relatedlicenses.isOfficial,
|
||||||
|
linkWikipedia: licenses.linkWikipedia,
|
||||||
|
linkSite: licenses.linkSite,
|
||||||
|
comments: licenses.comments,
|
||||||
|
})
|
||||||
|
.from(relatedlicenses)
|
||||||
|
.innerJoin(licenses, eq(licenses.id, relatedlicenses.licenseId))
|
||||||
|
.leftJoin(licensetypes, eq(licensetypes.id, licenses.licensetypeId))
|
||||||
|
.where(eq(relatedlicenses.entryId, id));
|
||||||
|
licenseRows = rows as typeof licenseRows;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: base.id,
|
id: base.id,
|
||||||
@@ -503,6 +601,15 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
|
|||||||
genre: { id: (base.genreId) ?? null, name: (base.genreName) ?? null },
|
genre: { id: (base.genreId) ?? null, name: (base.genreName) ?? null },
|
||||||
authors: authorRows,
|
authors: authorRows,
|
||||||
publishers: publisherRows,
|
publishers: publisherRows,
|
||||||
|
licenses: licenseRows.map((l) => ({
|
||||||
|
id: Number(l.id),
|
||||||
|
name: l.name,
|
||||||
|
type: { id: l.licensetypeId, name: l.licensetypeName ?? null },
|
||||||
|
isOfficial: !!l.isOfficial,
|
||||||
|
linkWikipedia: l.linkWikipedia ?? null,
|
||||||
|
linkSite: l.linkSite ?? null,
|
||||||
|
comments: l.comments ?? null,
|
||||||
|
})),
|
||||||
maxPlayers: (base.maxPlayers) ?? undefined,
|
maxPlayers: (base.maxPlayers) ?? undefined,
|
||||||
availabletypeId: (base.availabletypeId) ?? undefined,
|
availabletypeId: (base.availabletypeId) ?? undefined,
|
||||||
withoutLoadScreen: (base.withoutLoadScreen) ?? undefined,
|
withoutLoadScreen: (base.withoutLoadScreen) ?? undefined,
|
||||||
@@ -543,7 +650,21 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
|
|||||||
|
|
||||||
// ----- Labels -----
|
// ----- Labels -----
|
||||||
|
|
||||||
export type LabelDetail = LabelSummary;
|
export interface LabelDetail extends LabelSummary {
|
||||||
|
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;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface LabelSearchParams {
|
export interface LabelSearchParams {
|
||||||
q?: string;
|
q?: string;
|
||||||
@@ -589,7 +710,80 @@ export async function searchLabels(params: LabelSearchParams): Promise<PagedResu
|
|||||||
|
|
||||||
export async function getLabelById(id: number): Promise<LabelDetail | null> {
|
export async function getLabelById(id: number): Promise<LabelDetail | null> {
|
||||||
const rows = await db.select().from(labels).where(eq(labels.id, id)).limit(1);
|
const rows = await db.select().from(labels).where(eq(labels.id, id)).limit(1);
|
||||||
return (rows[0]) ?? null;
|
const base = rows[0];
|
||||||
|
if (!base) return null;
|
||||||
|
|
||||||
|
let permissionRows: {
|
||||||
|
websiteId: number | string;
|
||||||
|
websiteName: string;
|
||||||
|
websiteLink: string | null;
|
||||||
|
permissiontypeId: string;
|
||||||
|
permissiontypeName: string | null;
|
||||||
|
text: string | null;
|
||||||
|
}[] = [];
|
||||||
|
let licenseRows: {
|
||||||
|
id: number | string;
|
||||||
|
name: string;
|
||||||
|
licensetypeId: string;
|
||||||
|
licensetypeName: string | null;
|
||||||
|
linkWikipedia: string | null;
|
||||||
|
linkSite: string | null;
|
||||||
|
comments: string | null;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rowsPerm = await db
|
||||||
|
.select({
|
||||||
|
websiteId: websites.id,
|
||||||
|
websiteName: websites.name,
|
||||||
|
websiteLink: websites.link,
|
||||||
|
permissiontypeId: permissiontypes.id,
|
||||||
|
permissiontypeName: permissiontypes.name,
|
||||||
|
text: permissions.text,
|
||||||
|
})
|
||||||
|
.from(permissions)
|
||||||
|
.innerJoin(websites, eq(websites.id, permissions.websiteId))
|
||||||
|
.leftJoin(permissiontypes, eq(permissiontypes.id, permissions.permissiontypeId))
|
||||||
|
.where(eq(permissions.labelId, id));
|
||||||
|
permissionRows = rowsPerm as typeof permissionRows;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rowsLic = await db
|
||||||
|
.select({
|
||||||
|
id: licenses.id,
|
||||||
|
name: licenses.name,
|
||||||
|
licensetypeId: licenses.licensetypeId,
|
||||||
|
licensetypeName: licensetypes.name,
|
||||||
|
linkWikipedia: licenses.linkWikipedia,
|
||||||
|
linkSite: licenses.linkSite,
|
||||||
|
comments: licenses.comments,
|
||||||
|
})
|
||||||
|
.from(licensors)
|
||||||
|
.innerJoin(licenses, eq(licenses.id, licensors.licenseId))
|
||||||
|
.leftJoin(licensetypes, eq(licensetypes.id, licenses.licensetypeId))
|
||||||
|
.where(eq(licensors.labelId, id));
|
||||||
|
licenseRows = rowsLic as typeof licenseRows;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: base.id,
|
||||||
|
name: base.name,
|
||||||
|
labeltypeId: base.labeltypeId,
|
||||||
|
permissions: permissionRows.map((p) => ({
|
||||||
|
website: { id: Number(p.websiteId), name: p.websiteName, link: p.websiteLink ?? null },
|
||||||
|
type: { id: p.permissiontypeId, name: p.permissiontypeName ?? null },
|
||||||
|
text: p.text ?? null,
|
||||||
|
})),
|
||||||
|
licenses: licenseRows.map((l) => ({
|
||||||
|
id: Number(l.id),
|
||||||
|
name: l.name,
|
||||||
|
type: { id: l.licensetypeId, name: l.licensetypeName ?? null },
|
||||||
|
linkWikipedia: l.linkWikipedia ?? null,
|
||||||
|
linkSite: l.linkSite ?? null,
|
||||||
|
comments: l.comments ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LabelContribsParams {
|
export interface LabelContribsParams {
|
||||||
@@ -1024,11 +1218,21 @@ export async function entriesByMachinetype(
|
|||||||
export async function getEntryFacets(params: SearchParams): Promise<EntryFacets> {
|
export async function getEntryFacets(params: SearchParams): Promise<EntryFacets> {
|
||||||
const q = (params.q ?? "").trim();
|
const q = (params.q ?? "").trim();
|
||||||
const pattern = q ? `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%` : null;
|
const pattern = q ? `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%` : null;
|
||||||
|
const scope: EntrySearchScope = params.scope ?? "title";
|
||||||
|
|
||||||
// Build base WHERE SQL snippet considering q + filters
|
// Build base WHERE SQL snippet considering q + filters
|
||||||
const whereParts: Array<ReturnType<typeof sql>> = [];
|
const whereParts: Array<ReturnType<typeof sql>> = [];
|
||||||
if (pattern) {
|
if (pattern) {
|
||||||
whereParts.push(sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`);
|
if (scope !== "title") {
|
||||||
|
try {
|
||||||
|
const union = buildEntrySearchUnion(pattern, scope);
|
||||||
|
whereParts.push(sql`id in (select entry_id from (${union}) as matches)`);
|
||||||
|
} catch {
|
||||||
|
whereParts.push(sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
whereParts.push(sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (params.genreId) whereParts.push(sql`${entries.genretypeId} = ${params.genreId}`);
|
if (params.genreId) whereParts.push(sql`${entries.genretypeId} = ${params.genreId}`);
|
||||||
if (params.languageId) whereParts.push(sql`${entries.languageId} = ${params.languageId}`);
|
if (params.languageId) whereParts.push(sql`${entries.languageId} = ${params.languageId}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user