feat(guides): autosave structured guide data
CI / Verify (push) Successful in 57s
CI / Build immutable images and deploy (push) Successful in 3m10s

This commit is contained in:
2026-08-29 18:13:02 +00:00 Unverified
parent c4fcf3f996
commit ac9a733370
14 changed files with 3794 additions and 68 deletions
+1 -1
View File
@@ -20,5 +20,5 @@ export default async function StructuredEditorPage({ params }: { params: Promise
if (!guide) notFound();
const [catalog, content] = await Promise.all([getActiveCatalog(), getPublicGuide(guide.slug)]);
if (!catalog || !content) notFound();
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} initial={{ weapons: content.weapons, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent })) })), artifactProfile: content.artifactProfile, constellations: content.constellations, teams: content.teams.map((team) => ({ title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main></div>;
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={catalog.characters.find((item) => item.key === guide.characterKey)?.constellations ?? []} initial={{ weapons: content.weapons, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent })) })), artifactProfile: content.artifactProfile, constellations: content.constellations, teams: content.teams.map((team) => ({ title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main></div>;
}
+13
View File
@@ -0,0 +1,13 @@
import { AlertCircleIcon, CheckIcon, ClockIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import type { AutosaveState } from "@/hooks/use-autosave";
export function AutosaveStatus({ state }: { state: AutosaveState }) {
if (state === "saving") return <Badge variant="secondary"><Spinner data-icon="inline-start" />Saving…</Badge>;
if (state === "unsaved") return <Badge variant="outline"><ClockIcon data-icon="inline-start" />Waiting to save…</Badge>;
if (state === "incomplete") return <Badge variant="outline"><AlertCircleIcon data-icon="inline-start" />Complete required fields</Badge>;
if (state === "error") return <Badge variant="destructive"><AlertCircleIcon data-icon="inline-start" />Autosave failed</Badge>;
return <Badge variant="secondary"><CheckIcon data-icon="inline-start" />Saved</Badge>;
}
+11 -9
View File
@@ -1,23 +1,25 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { SaveIcon } from "lucide-react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { useState } from "react";
import { saveGuideOverview } from "@/app/admin/actions";
import { AutosaveStatus } from "@/components/admin/autosave-status";
import { MediaUploader } from "@/components/admin/media-uploader";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { useAutosave } from "@/hooks/use-autosave";
export function GuideOverviewForm({ guide }: { guide: { id: string; version: number; overview: string; coverMediaId: string; coverUrl: string; name: string } }) {
const router = useRouter();
const [overview, setOverview] = useState(guide.overview);
const [cover, setCover] = useState({ id: guide.coverMediaId, url: guide.coverUrl });
const [pending, startTransition] = useTransition();
return <Card><CardHeader><CardTitle>Overview</CardTitle><CardDescription>ภาพและข้อความนี้แสดงบนหน้าแรกและส่วนแนะนำของ Guide</CardDescription></CardHeader><CardContent><FieldGroup><Field><FieldLabel>Guide Image</FieldLabel><div className="relative aspect-video max-w-2xl overflow-hidden rounded-lg border"><Image src={cover.url} alt={`Guide ${guide.name}`} fill className="object-cover" /></div><MediaUploader onReady={(asset) => setCover(asset)} /></Field><Field><FieldLabel htmlFor="guide-overview">Overview</FieldLabel><Textarea id="guide-overview" value={overview} maxLength={500} onChange={(event) => setOverview(event.target.value)} /><FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription></Field></FieldGroup></CardContent><CardFooter className="justify-end"><Button disabled={pending || !overview.trim()} onClick={() => startTransition(async () => { const result = await saveGuideOverview(guide.id, { expectedVersion: guide.version, coverMediaId: cover.id, overview }); if (result.status === "error") { toast.error(result.message); return; } toast.success("บันทึก Overview แล้ว"); router.refresh(); })}>{pending ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}Save</Button></CardFooter></Card>;
const value = { coverMediaId: cover.id, overview };
const autosave = useAutosave({
value,
valid: Boolean(cover.id && overview.trim()),
initialVersion: guide.version,
persist: (next, expectedVersion) => saveGuideOverview(guide.id, { ...next, expectedVersion }),
});
return <Card><CardHeader><CardTitle>Overview</CardTitle><CardDescription>ภาพและข้อความนี้แสดงบนหน้าแรกและส่วนแนะนำของ Guide</CardDescription></CardHeader><CardContent><FieldGroup><Field><FieldLabel>Guide Image</FieldLabel><div className="relative aspect-video max-w-2xl overflow-hidden rounded-lg border"><Image src={cover.url} alt={`Guide ${guide.name}`} fill className="object-cover" /></div><MediaUploader onReady={(asset) => setCover(asset)} /></Field><Field><FieldLabel htmlFor="guide-overview">Overview</FieldLabel><Textarea id="guide-overview" value={overview} maxLength={500} onChange={(event) => setOverview(event.target.value)} /><FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription></Field></FieldGroup></CardContent><CardFooter className="justify-end"><AutosaveStatus state={autosave} /></CardFooter></Card>;
}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -48,8 +48,8 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) { const profile = gu
function StatLine({ label, values }: { label: string; values: string[] }) { return <div><p className="text-sm text-muted-foreground">{label}</p><div className="mt-1 flex flex-wrap gap-2">{values.map((value) => <Badge key={value} variant="outline">{value}</Badge>)}</div></div>; }
function ConstellationSection({ guide }: { guide: PublicGuide }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">กลุ่มดาว</p><h1 className="font-heading text-4xl font-semibold">Constellations</h1></header><div className="grid gap-4 md:grid-cols-2">{guide.constellations.map((row) => <Card key={row.level}><CardHeader><CardTitle>C{row.level}</CardTitle><CardDescription>{metric(row.cumulativePercent)} of C0</CardDescription></CardHeader><CardContent><p className="leading-7">{row.detail}</p></CardContent></Card>)}</div></section>; }
function ConstellationSection({ guide }: { guide: PublicGuide }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">กลุ่มดาว</p><h1 className="font-heading text-4xl font-semibold">Constellations</h1></header><div className="grid gap-4 md:grid-cols-2">{guide.constellations.map((row) => <Card key={row.level}><CardHeader>{row.iconUrl ? <Image src={row.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /> : null}<CardTitle>C{row.level} · {row.name}</CardTitle><CardDescription>{metric(row.cumulativePercent)} of C0</CardDescription></CardHeader><CardContent><p className="leading-7">{row.detail}</p></CardContent></Card>)}</div></section>; }
function TeamSection({ guide }: { guide: PublicGuide }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">คำนวณความเสียหาย</p><h1 className="font-heading text-4xl font-semibold">Team Calc</h1></header><div className="grid gap-5 xl:grid-cols-2">{guide.teams.map((team) => { const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0); const seconds = Number(team.rotationSeconds); return <Card key={team.id}><CardHeader><CardTitle>{team.title}</CardTitle><CardDescription>{seconds}s rotation</CardDescription></CardHeader><CardContent className="flex flex-col gap-4"><div className="grid grid-cols-4 gap-2">{team.members.map((member) => <div key={member.position} className="text-center"><div className="relative aspect-square overflow-hidden rounded-lg bg-muted"><Image src={member.character.imageUrl} alt={member.character.name} fill className="object-cover" /></div><p className="mt-2 truncate text-sm font-medium">{member.character.name}</p><p className="text-xs text-muted-foreground">{total ? (Number(member.damage) / total * 100).toFixed(2) : "0.00"}%</p></div>)}</div><Separator /><div className="flex items-end justify-between"><div><p className="text-sm text-muted-foreground">Total damage</p><p className="font-semibold">{total.toLocaleString()}</p></div><div className="text-right"><p className="text-sm text-muted-foreground">Total DPS</p><p className="text-3xl font-semibold text-primary">{(total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 })}</p></div></div><p className="rounded-lg bg-muted p-3 text-sm leading-6">{team.combo}</p></CardContent></Card>; })}</div></section>; }
function TeamSection({ guide }: { guide: PublicGuide }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">คำนวณความเสียหาย</p><h1 className="font-heading text-4xl font-semibold">Team Calc</h1></header><div className="grid gap-5 xl:grid-cols-2">{guide.teams.map((team) => { const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0); const seconds = Number(team.rotationSeconds); return <Card key={team.id}><CardHeader><CardTitle>{team.title}</CardTitle><CardDescription>{seconds}s rotation</CardDescription></CardHeader><CardContent className="flex flex-col gap-4"><div className="grid grid-cols-4 gap-2">{team.members.map((member) => <div key={member.position} className="text-center"><div className="relative aspect-square overflow-hidden rounded-lg bg-muted"><Image src={member.character.imageUrl} alt={member.character.name} fill className="object-cover" /></div><p className="mt-2 truncate text-sm font-medium">{member.character.name}</p><p className="text-sm font-semibold">{Number(member.damage).toLocaleString(undefined, { maximumFractionDigits: 2 })}</p><p className="text-xs text-muted-foreground">{total ? (Number(member.damage) / total * 100).toFixed(2) : "0.00"}%</p></div>)}</div><Separator /><div className="flex items-end justify-between"><div><p className="text-sm text-muted-foreground">Total damage</p><p className="font-semibold">{total.toLocaleString()}</p></div><div className="text-right"><p className="text-sm text-muted-foreground">Total DPS</p><p className="text-3xl font-semibold text-primary">{(total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 })}</p></div></div><p className="rounded-lg bg-muted p-3 text-sm leading-6">{team.combo}</p></CardContent></Card>; })}</div></section>; }
function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">Extra Guide</p><h1 className="font-heading text-4xl font-semibold">{extra.title}</h1></header><div className="grid gap-5 md:grid-cols-2">{extra.rows.map((row) => <Card key={row.id} className="overflow-hidden py-0">{row.imageUrl ? <div className="relative aspect-video bg-muted"><Image src={row.imageUrl} alt={row.heading} fill className="object-cover" /></div> : null}<CardHeader className="pt-5"><CardTitle>{row.heading}</CardTitle><CardDescription>{row.body}</CardDescription>{row.percent ? <Badge>{metric(row.percent)}</Badge> : null}</CardHeader></Card>)}</div></section>; }
+3
View File
@@ -15,6 +15,8 @@ import {
varchar,
} from "drizzle-orm/pg-core";
import type { CatalogConstellation } from "@/lib/catalog/lunaris";
const timestamps = {
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
@@ -445,6 +447,7 @@ export const catalogCharacters = pgTable(
rarity: integer("rarity"),
weaponType: varchar("weapon_type", { length: 64 }),
imageKey: text("image_key").notNull(),
constellations: jsonb("constellations").$type<CatalogConstellation[]>().default([]).notNull(),
},
(table) => [
primaryKey({ columns: [table.version, table.key] }),
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "catalog_character" ADD COLUMN "constellations" jsonb DEFAULT '[]'::jsonb NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -22,6 +22,13 @@
"when": 1788008368705,
"tag": "0002_absent_ben_parker",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1788027009371,
"tag": "0003_right_exiles",
"breakpoints": true
}
]
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useEffect, useEffectEvent, useState } from "react";
import { toast } from "sonner";
export type AutosaveState = "saved" | "unsaved" | "saving" | "incomplete" | "error";
interface SaveResult {
status: string;
message?: string;
version?: number;
}
export function useAutosave<T>({
value,
valid,
initialVersion,
persist,
delay = 900,
}: {
value: T;
valid: boolean;
initialVersion: number;
persist: (value: T, expectedVersion: number) => Promise<SaveResult>;
delay?: number;
}) {
const fingerprint = JSON.stringify(value);
const [savedFingerprint, setSavedFingerprint] = useState(fingerprint);
const [errorFingerprint, setErrorFingerprint] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [version, setVersion] = useState(initialVersion);
const save = useEffectEvent(async (snapshot: T, snapshotFingerprint: string) => {
setSaving(true);
setErrorFingerprint(null);
try {
const result = await persist(snapshot, version);
if (result.status === "error") {
setErrorFingerprint(snapshotFingerprint);
toast.error(result.message ?? "บันทึกอัตโนมัติไม่สำเร็จ");
return;
}
if (result.version) setVersion(result.version);
setSavedFingerprint(snapshotFingerprint);
} catch (cause) {
setErrorFingerprint(snapshotFingerprint);
toast.error(cause instanceof Error ? cause.message : "บันทึกอัตโนมัติไม่สำเร็จ");
} finally {
setSaving(false);
}
});
useEffect(() => {
if (!valid || saving || fingerprint === savedFingerprint || fingerprint === errorFingerprint) return;
const timer = window.setTimeout(() => {
void save(JSON.parse(fingerprint) as T, fingerprint);
}, delay);
return () => window.clearTimeout(timer);
}, [delay, errorFingerprint, fingerprint, savedFingerprint, saving, valid]);
if (!valid) return "incomplete";
if (saving) return "saving";
if (fingerprint === errorFingerprint) return "error";
if (fingerprint !== savedFingerprint) return "unsaved";
return "saved";
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { cleanLunarisText, constellationSnapshot } from "@/lib/catalog/lunaris";
describe("Lunaris character data", () => {
it("removes display markup from constellation text", () => {
expect(cleanLunarisText('"A {LINK#N1}<color=#fff>bright</color>{/LINK} morning"')).toBe("A bright morning");
});
it("normalizes constellation levels and icon keys", () => {
const result = constellationSnapshot({
constellations: {
const1: { name: '"First"', description: "Detail", icon: "UI_Talent_Test_01" },
},
}, (icon) => `catalog/1/constellations/${icon}.webp`);
expect(result).toEqual([{
level: 1,
name: "First",
description: "Detail",
iconKey: "catalog/1/constellations/UI_Talent_Test_01.webp",
}]);
});
});
+45
View File
@@ -0,0 +1,45 @@
import * as z from "zod";
export interface CatalogConstellation {
level: number;
name: string;
description: string;
iconKey: string | null;
}
const constellationSchema = z.object({
name: z.string(),
description: z.string(),
icon: z.string(),
});
export const characterDetailSchema = z.object({
constellations: z.record(z.string(), constellationSchema),
});
export function cleanLunarisText(value: string): string {
return value
.replace(/\{\/?LINK[^}]*\}/gu, "")
.replace(/<\/?color(?:=[^>]*)?>/gu, "")
.replace(/^"|"$/gu, "")
.trim();
}
export function constellationSnapshot(
value: z.infer<typeof characterDetailSchema>,
iconKey: (icon: string) => string,
): CatalogConstellation[] {
const result: CatalogConstellation[] = [];
for (const index of Array.from({ length: 6 }, (_, itemIndex) => itemIndex)) {
const level = index + 1;
const item = value.constellations[`const${level}`];
if (!item) continue;
result.push({
level,
name: cleanLunarisText(item.name),
description: cleanLunarisText(item.description),
iconKey: iconKey(item.icon),
});
}
return result;
}
+45 -7
View File
@@ -9,6 +9,7 @@ import {
catalogVersions,
catalogWeapons,
} from "@/db/schema";
import { characterDetailSchema, constellationSnapshot } from "@/lib/catalog/lunaris";
import { getMediaStorage } from "@/lib/media/storage";
const VERSION_URL = "https://api.lunaris.moe/data/version.json";
@@ -42,6 +43,13 @@ async function getJson(url: string): Promise<unknown> {
return response.json();
}
async function getOptionalJson(url: string): Promise<unknown | null> {
const response = await fetch(url, { signal: AbortSignal.timeout(30_000), cache: "no-store" });
if (response.status === 404) return null;
if (!response.ok) throw new Error(`Lunaris returned ${response.status} for ${url}`);
return response.json();
}
function webpName(value: string): string {
return value.endsWith(".webp") ? value : `${value}.webp`;
}
@@ -51,6 +59,19 @@ function rarity(value: string | undefined): number | null {
return { QUALITY_WHITE: 1, QUALITY_GREEN: 2, QUALITY_BLUE: 3, QUALITY_PURPLE: 4, QUALITY_ORANGE: 5 }[value] ?? null;
}
async function mapConcurrent<T, R>(items: T[], task: (item: T) => Promise<R>): Promise<R[]> {
const results = new Array<R>(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const index = next++;
results[index] = await task(items[index]);
}
}
await Promise.all(Array.from({ length: Math.min(4, items.length) }, () => worker()));
return results;
}
async function uploadAssets(
jobId: string,
assets: Array<{ source: string; target: string }>,
@@ -98,11 +119,19 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
const weaponData = weaponSchema.parse(weaponsRaw);
const artifactData = artifactSchema.parse(artifactsRaw);
let characters = Object.entries(characterData).filter(([, value]) => value.enName && value.enName !== "???" && value.GachaImg).map(([key, value]) => ({
version, key, name: value.enName, element: value.element ?? null, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null,
imageKey: `catalog/${version}/characters/${webpName(value.GachaImg)}`,
sourceImage: `${API_ROOT}/assets/gachaimg/${encodeURIComponent(webpName(value.GachaImg))}`,
}));
let characters = await mapConcurrent(
Object.entries(characterData).filter(([, value]) => value.enName && value.enName !== "???" && value.GachaImg),
async ([key, value]) => {
const detailRaw = await getOptionalJson(`${API_ROOT}/${version}/en/char/${encodeURIComponent(key)}.json`);
const detail = detailRaw ? characterDetailSchema.parse(detailRaw) : null;
return {
version, key, name: value.enName, element: value.element ?? null, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null,
imageKey: `catalog/${version}/characters/${webpName(value.GachaImg)}`,
sourceImage: `${API_ROOT}/assets/gachaimg/${encodeURIComponent(webpName(value.GachaImg))}`,
constellations: detail ? constellationSnapshot(detail, (icon) => `catalog/${version}/constellations/${webpName(icon)}`) : [],
};
},
);
let weapons = Object.entries(weaponData).filter(([, value]) => value.enName && value.enName !== "???" && value.weaponIcon).map(([key, value]) => ({
version, key, name: value.enName, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null,
imageKey: `catalog/${version}/weapons/${webpName(value.weaponIcon)}`,
@@ -115,14 +144,23 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
}));
const uniqueAssets = new Map<string, { source: string; target: string }>();
for (const item of [...characters, ...weapons, ...artifacts]) uniqueAssets.set(item.imageKey, { source: item.sourceImage, target: item.imageKey });
for (const character of characters) {
for (const constellation of character.constellations) {
if (constellation.iconKey) uniqueAssets.set(constellation.iconKey, {
source: `${API_ROOT}/assets/skills/${encodeURIComponent(constellation.iconKey.split("/").at(-1)!)}`,
target: constellation.iconKey,
});
}
}
const missingAssets = await uploadAssets(jobId, [...uniqueAssets.values()]);
characters = characters.filter((item) => !missingAssets.has(item.imageKey));
characters = characters.map((item) => ({ ...item, constellations: item.constellations.map((constellation) => ({ ...constellation, iconKey: constellation.iconKey && !missingAssets.has(constellation.iconKey) ? constellation.iconKey : null })) }));
weapons = weapons.filter((item) => !missingAssets.has(item.imageKey));
artifacts = artifacts.filter((item) => !missingAssets.has(item.imageKey));
const storage = await getMediaStorage();
await Promise.all([
storage.write(`catalog/${version}/characters.json`, JSON.stringify(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
storage.write(`catalog/${version}/characters.json`, JSON.stringify(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, constellations: item.constellations }))), { type: "application/json", acl: "public-read" }),
storage.write(`catalog/${version}/weapons.json`, JSON.stringify(weapons.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
storage.write(`catalog/${version}/artifacts.json`, JSON.stringify(artifacts.map((item) => ({ version: item.version, key: item.key, name: item.name, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
]);
@@ -132,7 +170,7 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
await tx.delete(catalogCharacters).where(eq(catalogCharacters.version, version));
await tx.delete(catalogWeapons).where(eq(catalogWeapons.version, version));
await tx.delete(catalogArtifacts).where(eq(catalogArtifacts.version, version));
if (characters.length) await tx.insert(catalogCharacters).values(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey })));
if (characters.length) await tx.insert(catalogCharacters).values(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, constellations: item.constellations })));
if (weapons.length) await tx.insert(catalogWeapons).values(weapons.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey })));
if (artifacts.length) await tx.insert(catalogArtifacts).values(artifacts.map((item) => ({ version: item.version, key: item.key, name: item.name, imageKey: item.imageKey })));
await tx.update(catalogVersions).set({ active: false });
+23 -4
View File
@@ -23,6 +23,17 @@ import {
} from "@/db/schema";
import { publicMediaUrl } from "@/lib/media/storage";
function catalogCharacterWithUrls<T extends typeof catalogCharacters.$inferSelect>(item: T) {
return {
...item,
imageUrl: publicMediaUrl(item.imageKey),
constellations: item.constellations.map((constellation) => ({
...constellation,
iconUrl: constellation.iconKey ? publicMediaUrl(constellation.iconKey) : null,
})),
};
}
export async function getActiveCatalog() {
const db = getDb();
const [version] = await db.select().from(catalogVersions).where(eq(catalogVersions.active, true)).limit(1);
@@ -34,7 +45,7 @@ export async function getActiveCatalog() {
]);
return {
version: version.version,
characters: characters.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
characters: characters.map(catalogCharacterWithUrls),
weapons: weapons.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
artifacts: artifacts.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
};
@@ -90,13 +101,21 @@ export async function getPublicGuide(slug: string) {
return {
...row.guide,
coverUrl: publicMediaUrl(row.cover.objectKey),
character: { ...row.character, imageUrl: publicMediaUrl(row.character.imageKey) },
character: catalogCharacterWithUrls(row.character),
sections,
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } })),
artifactGroups: groups.map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifacts.find((item) => item.key === recommendation.secondArtifactKey); return { ...recommendation, first: { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })),
artifactProfile: profile[0] ?? null,
constellations,
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => ({ ...member, character: { ...character, imageUrl: publicMediaUrl(character.imageKey) } })) })),
constellations: constellations.map((recommendation) => {
const catalog = row.character.constellations.find((item) => item.level === recommendation.level);
return {
...recommendation,
name: catalog?.name ?? `C${recommendation.level}`,
detail: catalog?.description ?? recommendation.detail,
iconUrl: catalog?.iconKey ? publicMediaUrl(catalog.iconKey) : null,
};
}),
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => ({ ...member, character: catalogCharacterWithUrls(character) })) })),
extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({ ...extraRow, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })) })),
};
}