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();
|
if (!guide) notFound();
|
||||||
const [catalog, content] = await Promise.all([getActiveCatalog(), getPublicGuide(guide.slug)]);
|
const [catalog, content] = await Promise.all([getActiveCatalog(), getPublicGuide(guide.slug)]);
|
||||||
if (!catalog || !content) notFound();
|
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";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useState } from "react";
|
||||||
import { SaveIcon } from "lucide-react";
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { saveGuideOverview } from "@/app/admin/actions";
|
import { saveGuideOverview } from "@/app/admin/actions";
|
||||||
|
import { AutosaveStatus } from "@/components/admin/autosave-status";
|
||||||
import { MediaUploader } from "@/components/admin/media-uploader";
|
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 { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 } }) {
|
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 [overview, setOverview] = useState(guide.overview);
|
||||||
const [cover, setCover] = useState({ id: guide.coverMediaId, url: guide.coverUrl });
|
const [cover, setCover] = useState({ id: guide.coverMediaId, url: guide.coverUrl });
|
||||||
const [pending, startTransition] = useTransition();
|
const value = { coverMediaId: cover.id, overview };
|
||||||
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 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 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>; }
|
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,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
import type { CatalogConstellation } from "@/lib/catalog/lunaris";
|
||||||
|
|
||||||
const timestamps = {
|
const timestamps = {
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
@@ -445,6 +447,7 @@ export const catalogCharacters = pgTable(
|
|||||||
rarity: integer("rarity"),
|
rarity: integer("rarity"),
|
||||||
weaponType: varchar("weapon_type", { length: 64 }),
|
weaponType: varchar("weapon_type", { length: 64 }),
|
||||||
imageKey: text("image_key").notNull(),
|
imageKey: text("image_key").notNull(),
|
||||||
|
constellations: jsonb("constellations").$type<CatalogConstellation[]>().default([]).notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
primaryKey({ columns: [table.version, table.key] }),
|
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,
|
"when": 1788008368705,
|
||||||
"tag": "0002_absent_ben_parker",
|
"tag": "0002_absent_ben_parker",
|
||||||
"breakpoints": true
|
"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,
|
catalogVersions,
|
||||||
catalogWeapons,
|
catalogWeapons,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
|
import { characterDetailSchema, constellationSnapshot } from "@/lib/catalog/lunaris";
|
||||||
import { getMediaStorage } from "@/lib/media/storage";
|
import { getMediaStorage } from "@/lib/media/storage";
|
||||||
|
|
||||||
const VERSION_URL = "https://api.lunaris.moe/data/version.json";
|
const VERSION_URL = "https://api.lunaris.moe/data/version.json";
|
||||||
@@ -42,6 +43,13 @@ async function getJson(url: string): Promise<unknown> {
|
|||||||
return response.json();
|
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 {
|
function webpName(value: string): string {
|
||||||
return value.endsWith(".webp") ? value : `${value}.webp`;
|
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;
|
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(
|
async function uploadAssets(
|
||||||
jobId: string,
|
jobId: string,
|
||||||
assets: Array<{ source: string; target: 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 weaponData = weaponSchema.parse(weaponsRaw);
|
||||||
const artifactData = artifactSchema.parse(artifactsRaw);
|
const artifactData = artifactSchema.parse(artifactsRaw);
|
||||||
|
|
||||||
let characters = Object.entries(characterData).filter(([, value]) => value.enName && value.enName !== "???" && value.GachaImg).map(([key, value]) => ({
|
let characters = await mapConcurrent(
|
||||||
version, key, name: value.enName, element: value.element ?? null, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null,
|
Object.entries(characterData).filter(([, value]) => value.enName && value.enName !== "???" && value.GachaImg),
|
||||||
imageKey: `catalog/${version}/characters/${webpName(value.GachaImg)}`,
|
async ([key, value]) => {
|
||||||
sourceImage: `${API_ROOT}/assets/gachaimg/${encodeURIComponent(webpName(value.GachaImg))}`,
|
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]) => ({
|
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,
|
version, key, name: value.enName, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null,
|
||||||
imageKey: `catalog/${version}/weapons/${webpName(value.weaponIcon)}`,
|
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 }>();
|
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 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()]);
|
const missingAssets = await uploadAssets(jobId, [...uniqueAssets.values()]);
|
||||||
characters = characters.filter((item) => !missingAssets.has(item.imageKey));
|
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));
|
weapons = weapons.filter((item) => !missingAssets.has(item.imageKey));
|
||||||
artifacts = artifacts.filter((item) => !missingAssets.has(item.imageKey));
|
artifacts = artifacts.filter((item) => !missingAssets.has(item.imageKey));
|
||||||
|
|
||||||
const storage = await getMediaStorage();
|
const storage = await getMediaStorage();
|
||||||
await Promise.all([
|
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}/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" }),
|
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(catalogCharacters).where(eq(catalogCharacters.version, version));
|
||||||
await tx.delete(catalogWeapons).where(eq(catalogWeapons.version, version));
|
await tx.delete(catalogWeapons).where(eq(catalogWeapons.version, version));
|
||||||
await tx.delete(catalogArtifacts).where(eq(catalogArtifacts.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 (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 })));
|
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 });
|
await tx.update(catalogVersions).set({ active: false });
|
||||||
|
|||||||
+23
-4
@@ -23,6 +23,17 @@ import {
|
|||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { publicMediaUrl } from "@/lib/media/storage";
|
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() {
|
export async function getActiveCatalog() {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const [version] = await db.select().from(catalogVersions).where(eq(catalogVersions.active, true)).limit(1);
|
const [version] = await db.select().from(catalogVersions).where(eq(catalogVersions.active, true)).limit(1);
|
||||||
@@ -34,7 +45,7 @@ export async function getActiveCatalog() {
|
|||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
version: version.version,
|
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) })),
|
weapons: weapons.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
|
||||||
artifacts: artifacts.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 {
|
return {
|
||||||
...row.guide,
|
...row.guide,
|
||||||
coverUrl: publicMediaUrl(row.cover.objectKey),
|
coverUrl: publicMediaUrl(row.cover.objectKey),
|
||||||
character: { ...row.character, imageUrl: publicMediaUrl(row.character.imageKey) },
|
character: catalogCharacterWithUrls(row.character),
|
||||||
sections,
|
sections,
|
||||||
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } })),
|
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 }; }) })),
|
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,
|
artifactProfile: profile[0] ?? null,
|
||||||
constellations,
|
constellations: constellations.map((recommendation) => {
|
||||||
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => ({ ...member, character: { ...character, imageUrl: publicMediaUrl(character.imageKey) } })) })),
|
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 })) })),
|
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