Standardize ZXDB UI; add SSR search/tables

Unify the look and feel of all /zxdb pages and minimize client pop-in.

- Make all /zxdb pages full-width to match /explorer
- Convert Languages, Genres, Machine Types, and Labels lists to
  Bootstrap tables with table-striped and table-hover inside
  table-responsive wrappers
- Replace raw FK IDs with linked names via SSR repository joins
- Add scoped search boxes on detail pages (labels, genres, languages,
  machine types) with SSR filtering and pagination that preserves q/tab
- Keep explorer results consistent: show Machine/Language names with
  links, no client lookups required

This improves consistency, readability, and first paint stability across
the ZXDB section while keeping navigation fast and discoverable.

Signed-off-by: Junie@lucy.xalior.com
This commit is contained in:
2025-12-12 16:58:50 +00:00
parent ddbf72ea52
commit 240936a850
18 changed files with 873 additions and 147 deletions

View File

@@ -28,7 +28,9 @@ export interface SearchResultItem {
title: string;
isXrated: number;
machinetypeId: number | null;
machinetypeName?: string | null;
languageId: string | null;
languageName?: string | null;
}
export interface PagedResult<T> {
@@ -70,8 +72,18 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
const [items, countRows] = await Promise.all([
db
.select()
.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 as any))
.leftJoin(languages, eq(languages.id, entries.languageId as any))
.where(whereExpr as any)
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
.limit(pageSize)
@@ -102,10 +114,14 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(searchByTitles)
.innerJoin(entries, eq(entries.id, searchByTitles.entryId))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
.leftJoin(languages, eq(languages.id, entries.languageId as any))
.where(like(searchByTitles.entryTitle, pattern))
.groupBy(entries.id)
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
@@ -130,6 +146,12 @@ export interface EntryDetail {
genre: { id: number | null; name: string | null };
authors: LabelSummary[];
publishers: LabelSummary[];
// Additional entry fields for richer details
maxPlayers?: number;
availabletypeId?: string | null;
withoutLoadScreen?: number;
withoutInlay?: number;
issueId?: number | null;
}
export async function getEntryById(id: number): Promise<EntryDetail | null> {
@@ -146,6 +168,11 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
languageName: languages.name,
genreId: entries.genretypeId,
genreName: genretypes.name,
maxPlayers: entries.maxPlayers,
availabletypeId: entries.availabletypeId,
withoutLoadScreen: entries.withoutLoadScreen,
withoutInlay: entries.withoutInlay,
issueId: entries.issueId,
})
.from(entries)
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
@@ -178,6 +205,11 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
genre: { id: (base.genreId as any) ?? null, name: (base.genreName as any) ?? null },
authors: authorRows as any,
publishers: publisherRows as any,
maxPlayers: (base.maxPlayers as any) ?? undefined,
availabletypeId: (base.availabletypeId as any) ?? undefined,
withoutLoadScreen: (base.withoutLoadScreen as any) ?? undefined,
withoutInlay: (base.withoutInlay as any) ?? undefined,
issueId: (base.issueId as any) ?? undefined,
};
}
@@ -237,30 +269,67 @@ export async function getLabelById(id: number): Promise<LabelDetail | null> {
export interface LabelContribsParams {
page?: number;
pageSize?: number;
q?: string; // optional title filter
}
export async function getLabelAuthoredEntries(labelId: number, params: LabelContribsParams): Promise<PagedResult<SearchResultItem>> {
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
const page = Math.max(1, params.page ?? 1);
const offset = (page - 1) * pageSize;
const hasQ = !!(params.q && params.q.trim());
if (!hasQ) {
const countRows = await db
.select({ total: sql<number>`count(distinct ${authors.entryId})` })
.from(authors)
.where(eq(authors.labelId, labelId));
const total = Number(countRows[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(authors)
.innerJoin(entries, eq(entries.id, authors.entryId))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
.leftJoin(languages, eq(languages.id, entries.languageId as any))
.where(eq(authors.labelId, labelId))
.groupBy(entries.id)
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items: items as any, page, pageSize, total };
}
const pattern = `%${params.q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
const countRows = await db
.select({ total: sql<number>`count(distinct ${authors.entryId})` })
.select({ total: sql<number>`count(distinct ${entries.id})` })
.from(authors)
.where(eq(authors.labelId, labelId));
const total = Number(countRows[0]?.total ?? 0);
.innerJoin(entries, eq(entries.id, authors.entryId))
.where(and(eq(authors.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any);
const total = Number((countRows as any)[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(authors)
.innerJoin(entries, eq(entries.id, authors.entryId))
.where(eq(authors.labelId, labelId))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
.leftJoin(languages, eq(languages.id, entries.languageId as any))
.where(and(eq(authors.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any)
.groupBy(entries.id)
.orderBy(entries.title)
.limit(pageSize)
@@ -273,24 +342,60 @@ export async function getLabelPublishedEntries(labelId: number, params: LabelCon
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
const page = Math.max(1, params.page ?? 1);
const offset = (page - 1) * pageSize;
const hasQ = !!(params.q && params.q.trim());
if (!hasQ) {
const countRows = await db
.select({ total: sql<number>`count(distinct ${publishers.entryId})` })
.from(publishers)
.where(eq(publishers.labelId, labelId));
const total = Number(countRows[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(publishers)
.innerJoin(entries, eq(entries.id, publishers.entryId))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
.leftJoin(languages, eq(languages.id, entries.languageId as any))
.where(eq(publishers.labelId, labelId))
.groupBy(entries.id)
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items: items as any, page, pageSize, total };
}
const pattern = `%${params.q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
const countRows = await db
.select({ total: sql<number>`count(distinct ${publishers.entryId})` })
.select({ total: sql<number>`count(distinct ${entries.id})` })
.from(publishers)
.where(eq(publishers.labelId, labelId));
const total = Number(countRows[0]?.total ?? 0);
.innerJoin(entries, eq(entries.id, publishers.entryId))
.where(and(eq(publishers.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any);
const total = Number((countRows as any)[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(publishers)
.innerJoin(entries, eq(entries.id, publishers.entryId))
.where(eq(publishers.labelId, labelId))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
.leftJoin(languages, eq(languages.id, entries.languageId as any))
.where(and(eq(publishers.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any)
.groupBy(entries.id)
.orderBy(entries.title)
.limit(pageSize)
@@ -408,52 +513,187 @@ export async function searchMachinetypes(params: SimpleSearchParams) {
return { items: items as any, page, pageSize, total };
}
export async function entriesByGenre(genreId: number, page: number, pageSize: number): Promise<PagedResult<SearchResultItem>> {
export async function entriesByGenre(
genreId: number,
page: number,
pageSize: number,
q?: string
): Promise<PagedResult<SearchResultItem>> {
const offset = (page - 1) * pageSize;
const countRows = (await db
.select({ total: sql<number>`count(*)` })
const hasQ = !!(q && q.trim());
if (!hasQ) {
const countRows = (await db
.select({ total: sql<number>`count(*)` })
.from(entries)
.where(eq(entries.genretypeId, genreId as any))) as unknown as { total: number }[];
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 as any))
.leftJoin(languages, eq(languages.id, entries.languageId as any))
.where(eq(entries.genretypeId, genreId as any))
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) };
}
const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
const countRows = await db
.select({ total: sql<number>`count(distinct ${entries.id})` })
.from(entries)
.where(eq(entries.genretypeId, genreId as any))) as unknown as { total: number }[];
.where(and(eq(entries.genretypeId, genreId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any);
const total = Number((countRows as any)[0]?.total ?? 0);
const items = await db
.select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId })
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(entries)
.where(eq(entries.genretypeId, genreId as any))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
.leftJoin(languages, eq(languages.id, entries.languageId as any))
.where(and(eq(entries.genretypeId, genreId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any)
.groupBy(entries.id)
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) };
return { items: items as any, page, pageSize, total };
}
export async function entriesByLanguage(langId: string, page: number, pageSize: number): Promise<PagedResult<SearchResultItem>> {
export async function entriesByLanguage(
langId: string,
page: number,
pageSize: number,
q?: string
): Promise<PagedResult<SearchResultItem>> {
const offset = (page - 1) * pageSize;
const countRows = (await db
.select({ total: sql<number>`count(*)` })
const hasQ = !!(q && q.trim());
if (!hasQ) {
const countRows = (await db
.select({ total: sql<number>`count(*)` })
.from(entries)
.where(eq(entries.languageId, langId as any))) as unknown as { total: number }[];
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 as any))
.leftJoin(languages, eq(languages.id, entries.languageId as any))
.where(eq(entries.languageId, langId as any))
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) };
}
const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
const countRows = await db
.select({ total: sql<number>`count(distinct ${entries.id})` })
.from(entries)
.where(eq(entries.languageId, langId as any))) as unknown as { total: number }[];
.where(and(eq(entries.languageId, langId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any);
const total = Number((countRows as any)[0]?.total ?? 0);
const items = await db
.select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId })
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(entries)
.where(eq(entries.languageId, langId as any))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
.leftJoin(languages, eq(languages.id, entries.languageId as any))
.where(and(eq(entries.languageId, langId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any)
.groupBy(entries.id)
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) };
return { items: items as any, page, pageSize, total };
}
export async function entriesByMachinetype(mtId: number, page: number, pageSize: number): Promise<PagedResult<SearchResultItem>> {
export async function entriesByMachinetype(
mtId: number,
page: number,
pageSize: number,
q?: string
): Promise<PagedResult<SearchResultItem>> {
const offset = (page - 1) * pageSize;
const countRows = (await db
.select({ total: sql<number>`count(*)` })
const hasQ = !!(q && q.trim());
if (!hasQ) {
const countRows = (await db
.select({ total: sql<number>`count(*)` })
.from(entries)
.where(eq(entries.machinetypeId, mtId as any))) as unknown as { total: number }[];
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 as any))
.leftJoin(languages, eq(languages.id, entries.languageId as any))
.where(eq(entries.machinetypeId, mtId as any))
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) };
}
const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
const countRows = await db
.select({ total: sql<number>`count(distinct ${entries.id})` })
.from(entries)
.where(eq(entries.machinetypeId, mtId as any))) as unknown as { total: number }[];
.where(and(eq(entries.machinetypeId, mtId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any);
const total = Number((countRows as any)[0]?.total ?? 0);
const items = await db
.select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId })
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(entries)
.where(eq(entries.machinetypeId, mtId as any))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
.leftJoin(languages, eq(languages.id, entries.languageId as any))
.where(and(eq(entries.machinetypeId, mtId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any)
.groupBy(entries.id)
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) };
return { items: items as any, page, pageSize, total };
}
// ----- Facets for search -----