feat(guides): autosave structured guide data
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
@@ -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>; }
|
||||
|
||||
@@ -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] }),
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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",
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 })) })),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user