334 lines
8.9 KiB
TypeScript
334 lines
8.9 KiB
TypeScript
import "server-only";
|
|
|
|
import { and, asc, eq } from "drizzle-orm";
|
|
|
|
import { getDb } from "@/db";
|
|
import {
|
|
blocks,
|
|
characters,
|
|
dataSources,
|
|
dataSourceVersions,
|
|
pageDataSources,
|
|
pages,
|
|
slugAliases,
|
|
templates,
|
|
} from "@/db/schema";
|
|
|
|
import type {
|
|
CharacterIdentity,
|
|
DataSourceSnapshot,
|
|
PageIdentity,
|
|
PageSnapshot,
|
|
PublicCharacterCard,
|
|
} from "./types";
|
|
|
|
export type PageLookup =
|
|
| { type: "page"; snapshot: PageSnapshot }
|
|
| { type: "redirect"; location: string }
|
|
| { type: "not-found" };
|
|
|
|
function characterIdentity(
|
|
character: typeof characters.$inferSelect,
|
|
): CharacterIdentity {
|
|
return {
|
|
id: character.id,
|
|
slug: character.slug,
|
|
nameTh: character.nameTh,
|
|
nameEn: character.nameEn,
|
|
description: character.description,
|
|
portraitMediaId: character.portraitMediaId,
|
|
element: character.element,
|
|
role: character.role,
|
|
rarity: character.rarity,
|
|
sortOrder: character.sortOrder,
|
|
visible: character.visible,
|
|
};
|
|
}
|
|
|
|
function pageIdentity(page: typeof pages.$inferSelect): PageIdentity {
|
|
return {
|
|
id: page.id,
|
|
characterId: page.characterId,
|
|
slug: page.slug,
|
|
title: page.title,
|
|
navOrder: page.navOrder,
|
|
visible: page.visible,
|
|
publicNote: page.publicNote,
|
|
version: page.version,
|
|
};
|
|
}
|
|
|
|
export async function listPublicCharacters(): Promise<PublicCharacterCard[]> {
|
|
const db = getDb();
|
|
const rows = await db
|
|
.select({ character: characters, page: pages })
|
|
.from(characters)
|
|
.innerJoin(
|
|
pages,
|
|
and(eq(pages.characterId, characters.id), eq(pages.visible, true)),
|
|
)
|
|
.where(eq(characters.visible, true))
|
|
.orderBy(asc(characters.sortOrder), asc(pages.navOrder), asc(pages.title));
|
|
|
|
const directory = new Map<string, PublicCharacterCard>();
|
|
for (const { character, page } of rows) {
|
|
if (directory.has(character.id)) continue;
|
|
directory.set(character.id, {
|
|
id: character.id,
|
|
slug: character.slug,
|
|
nameTh: character.nameTh,
|
|
nameEn: character.nameEn,
|
|
portraitMediaId: character.portraitMediaId,
|
|
element: character.element,
|
|
role: character.role,
|
|
rarity: character.rarity,
|
|
firstPageSlug: page.slug,
|
|
});
|
|
}
|
|
return [...directory.values()];
|
|
}
|
|
|
|
async function buildPageSnapshot(
|
|
character: typeof characters.$inferSelect,
|
|
page: typeof pages.$inferSelect,
|
|
includeHidden: boolean,
|
|
): Promise<PageSnapshot> {
|
|
const db = getDb();
|
|
const [navigation, blockRows, dependencies] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(pages)
|
|
.where(
|
|
includeHidden
|
|
? eq(pages.characterId, character.id)
|
|
: and(eq(pages.characterId, character.id), eq(pages.visible, true)),
|
|
)
|
|
.orderBy(asc(pages.navOrder), asc(pages.title)),
|
|
db
|
|
.select()
|
|
.from(blocks)
|
|
.where(eq(blocks.pageId, page.id))
|
|
.orderBy(asc(blocks.sortOrder)),
|
|
db
|
|
.select({
|
|
source: dataSources,
|
|
pinnedVersion: pageDataSources.pinnedVersion,
|
|
})
|
|
.from(pageDataSources)
|
|
.innerJoin(dataSources, eq(dataSources.id, pageDataSources.dataSourceId))
|
|
.where(eq(pageDataSources.pageId, page.id)),
|
|
]);
|
|
|
|
const versionVector = Object.fromEntries(
|
|
dependencies.map(({ source, pinnedVersion }) => [
|
|
source.id,
|
|
pinnedVersion ?? source.currentVersion,
|
|
]),
|
|
);
|
|
const sourceSnapshots: Record<string, DataSourceSnapshot> = {};
|
|
|
|
await Promise.all(
|
|
dependencies.map(async ({ source, pinnedVersion }) => {
|
|
const version = pinnedVersion ?? source.currentVersion;
|
|
const [versionRow] = await db
|
|
.select()
|
|
.from(dataSourceVersions)
|
|
.where(
|
|
and(
|
|
eq(dataSourceVersions.dataSourceId, source.id),
|
|
eq(dataSourceVersions.version, version),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!versionRow) return;
|
|
sourceSnapshots[source.id] = {
|
|
id: source.id,
|
|
name: source.name,
|
|
version,
|
|
fields: source.fields as DataSourceSnapshot["fields"],
|
|
rows: versionRow.rows as DataSourceSnapshot["rows"],
|
|
};
|
|
}),
|
|
);
|
|
|
|
return {
|
|
character: characterIdentity(character),
|
|
page: pageIdentity(page),
|
|
pages: navigation.map(pageIdentity),
|
|
blocks: blockRows.map((block) => ({
|
|
id: block.id,
|
|
type: block.type,
|
|
schemaVersion: block.schemaVersion,
|
|
config: block.config,
|
|
responsiveLayout: block.responsiveLayout,
|
|
sortOrder: block.sortOrder,
|
|
})),
|
|
dataSources: sourceSnapshots,
|
|
versionVector,
|
|
};
|
|
}
|
|
|
|
export async function getPublicPage(
|
|
requestedCharacterSlug: string,
|
|
requestedPageSlug?: string,
|
|
): Promise<PageLookup> {
|
|
const db = getDb();
|
|
let [character] = await db
|
|
.select()
|
|
.from(characters)
|
|
.where(
|
|
and(
|
|
eq(characters.slug, requestedCharacterSlug),
|
|
eq(characters.visible, true),
|
|
),
|
|
)
|
|
.limit(1);
|
|
const characterIsAlias = !character;
|
|
|
|
if (!character) {
|
|
const [alias] = await db
|
|
.select({ target: characters })
|
|
.from(slugAliases)
|
|
.innerJoin(
|
|
characters,
|
|
eq(characters.id, slugAliases.targetCharacterId),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(slugAliases.kind, "character"),
|
|
eq(slugAliases.oldCharacterSlug, requestedCharacterSlug),
|
|
eq(characters.visible, true),
|
|
),
|
|
)
|
|
.limit(1);
|
|
character = alias?.target;
|
|
}
|
|
|
|
if (!character) return { type: "not-found" };
|
|
|
|
const visiblePages = await db
|
|
.select()
|
|
.from(pages)
|
|
.where(
|
|
and(eq(pages.characterId, character.id), eq(pages.visible, true)),
|
|
)
|
|
.orderBy(asc(pages.navOrder), asc(pages.title));
|
|
|
|
if (visiblePages.length === 0) return { type: "not-found" };
|
|
if (!requestedPageSlug) {
|
|
return {
|
|
type: "redirect",
|
|
location: `/${character.slug}/${visiblePages[0].slug}`,
|
|
};
|
|
}
|
|
|
|
const page = visiblePages.find((item) => item.slug === requestedPageSlug);
|
|
if (page) {
|
|
if (characterIsAlias) {
|
|
return {
|
|
type: "redirect",
|
|
location: `/${character.slug}/${page.slug}`,
|
|
};
|
|
}
|
|
return {
|
|
type: "page",
|
|
snapshot: await buildPageSnapshot(character, page, false),
|
|
};
|
|
}
|
|
|
|
const [alias] = await db
|
|
.select({ target: pages })
|
|
.from(slugAliases)
|
|
.innerJoin(pages, eq(pages.id, slugAliases.targetPageId))
|
|
.where(
|
|
and(
|
|
eq(slugAliases.kind, "page"),
|
|
eq(slugAliases.oldCharacterSlug, requestedCharacterSlug),
|
|
eq(slugAliases.oldPageSlug, requestedPageSlug),
|
|
eq(pages.visible, true),
|
|
),
|
|
)
|
|
.limit(1);
|
|
|
|
if (!alias?.target || alias.target.characterId !== character.id) {
|
|
return { type: "not-found" };
|
|
}
|
|
return {
|
|
type: "redirect",
|
|
location: `/${character.slug}/${alias.target.slug}`,
|
|
};
|
|
}
|
|
|
|
export async function getAdminPage(
|
|
characterSlug: string,
|
|
pageSlug: string,
|
|
): Promise<PageSnapshot | null> {
|
|
const db = getDb();
|
|
const [row] = await db
|
|
.select({ character: characters, page: pages })
|
|
.from(characters)
|
|
.innerJoin(pages, eq(pages.characterId, characters.id))
|
|
.where(
|
|
and(eq(characters.slug, characterSlug), eq(pages.slug, pageSlug)),
|
|
)
|
|
.limit(1);
|
|
return row
|
|
? buildPageSnapshot(row.character, row.page, true)
|
|
: null;
|
|
}
|
|
|
|
export async function getAdminNavigation() {
|
|
const db = getDb();
|
|
const [characterRows, pageRows, templateRows] = await Promise.all([
|
|
db.select().from(characters).orderBy(asc(characters.sortOrder), asc(characters.nameTh)),
|
|
db.select().from(pages).orderBy(asc(pages.navOrder), asc(pages.title)),
|
|
db.select().from(templates).orderBy(asc(templates.name)),
|
|
]);
|
|
return {
|
|
characters: characterRows.map((character) => ({
|
|
...characterIdentity(character),
|
|
pages: pageRows
|
|
.filter((page) => page.characterId === character.id)
|
|
.map(pageIdentity),
|
|
})),
|
|
templates: templateRows.map((template) => ({
|
|
id: template.id,
|
|
slug: template.slug,
|
|
name: template.name,
|
|
description: template.description,
|
|
category: template.category,
|
|
builtIn: template.builtIn,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export async function getMediaVisibility(mediaId: string): Promise<{
|
|
public: boolean;
|
|
retained: boolean;
|
|
}> {
|
|
const db = getDb();
|
|
const referencedBlocks = await db
|
|
.select({ block: blocks, page: pages, character: characters })
|
|
.from(blocks)
|
|
.innerJoin(pages, eq(pages.id, blocks.pageId))
|
|
.innerJoin(characters, eq(characters.id, pages.characterId));
|
|
|
|
const hasMediaId = (value: unknown): boolean => {
|
|
if (Array.isArray(value)) return value.some(hasMediaId);
|
|
if (!value || typeof value !== "object") return false;
|
|
return Object.entries(value).some(
|
|
([key, nested]) =>
|
|
(key === "mediaId" && nested === mediaId) || hasMediaId(nested),
|
|
);
|
|
};
|
|
const references = referencedBlocks.filter(({ block }) =>
|
|
hasMediaId(block.config),
|
|
);
|
|
return {
|
|
public: references.some(
|
|
({ page, character }) => page.visible && character.visible,
|
|
),
|
|
retained: references.length > 0,
|
|
};
|
|
}
|