diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index 0d21736..78263c3 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -57,8 +57,10 @@ import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation import { DeleteConditionDialog } from "@/components/admin/delete-condition-dialog"; import { MediaUploader } from "@/components/admin/media-uploader"; import { ScrollableNav } from "@/components/public/scrollable-nav"; +import { LunarisText } from "@/components/public/lunaris-text"; import { TeamArtifactIcon } from "@/components/public/team-search"; import { SimpleTooltip } from "@/components/tooltip"; +import type { CatalogTextNode } from "@/lib/catalog/lunaris"; import { Accordion, AccordionContent, @@ -299,6 +301,7 @@ type CatalogConstellation = { level: number; name: string; description: string; + descriptionNodes?: CatalogTextNode[]; iconUrl: string | null; }; type CatalogTalent = { @@ -2474,6 +2477,7 @@ function ConstellationEditor({ name: `C${index + 1}`, description: savedRows.find((row) => row.level === index + 1)?.detail ?? "", + descriptionNodes: undefined, iconUrl: null, })); return source.map((constellation) => { @@ -2643,9 +2647,11 @@ function ConstellationEditor({ /> ) : null} - C{row.level} · {row.name} + C{row.level} - {row.name} - {row.description} + + + diff --git a/components/public/lunaris-text.tsx b/components/public/lunaris-text.tsx new file mode 100644 index 0000000..7ef6f81 --- /dev/null +++ b/components/public/lunaris-text.tsx @@ -0,0 +1,34 @@ +import { Fragment, type ReactNode } from "react"; + +import { TermDetail } from "@/components/public/term-detail"; +import type { CatalogTextNode } from "@/lib/catalog/lunaris"; + +function renderNodes(nodes: CatalogTextNode[]): ReactNode { + return nodes.map((node, index) => { + if (node.type === "text") return {node.value}; + const children = renderNodes(node.children); + if (node.type === "color") { + return {children}; + } + if (!node.detail) return {children}; + return ( + + ); + }); +} + +export function LunarisText({ + nodes, + fallback, +}: { + nodes?: CatalogTextNode[]; + fallback: string; +}) { + return <>{nodes?.length ? renderNodes(nodes) : fallback}; +} diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx index 9567ce3..06921e6 100644 --- a/components/public/structured-guide.tsx +++ b/components/public/structured-guide.tsx @@ -34,6 +34,7 @@ import { WeaponDisplayMode, WeaponDisplayModeProvider } from "@/components/publi import { ArtifactImageExport } from "@/components/public/artifact-image-export"; import { GuideExportBranding } from "@/components/public/guide-export-branding"; import { ConstellationImage } from "@/components/public/constellation-image"; +import { LunarisText } from "@/components/public/lunaris-text"; import { ConditionSwitcher } from "@/components/public/condition-switcher"; import { MaterialPoster } from "@/components/public/material-poster"; import { RichTextContent } from "@/components/rich-text-content"; @@ -1226,12 +1227,14 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) { {metric(row.cumulativePercent)} - C{row.level} · {row.name} + C{row.level} - {row.name} -

{row.detail}

+
+ +
))} diff --git a/components/public/term-detail.tsx b/components/public/term-detail.tsx index 66f5121..df8f687 100644 --- a/components/public/term-detail.tsx +++ b/components/public/term-detail.tsx @@ -1,7 +1,7 @@ "use client"; import { InfoIcon } from "lucide-react"; -import { useSyncExternalStore } from "react"; +import { useSyncExternalStore, type ReactNode } from "react"; import { Drawer, @@ -29,33 +29,37 @@ function getSnapshot() { return window.matchMedia(finePointerQuery).matches; } -function Trigger({ label }: { label: string }) { +function Trigger({ label, showIcon }: { label: ReactNode; showIcon: boolean }) { return ( - + {label} - ); } export function TermDetail({ label, + title, detail, + showIcon = true, }: { - label: string; + label: ReactNode; + title: string; detail: string; + showIcon?: boolean; }) { const finePointer = useSyncExternalStore(subscribe, getSnapshot, () => false); if (finePointer) { return ( - - + + -

{label}

-

{detail}

+

{title}

+

{detail}

); @@ -63,13 +67,13 @@ export function TermDetail({ return ( - - + + - {label} - {detail} + {title} + {detail}
diff --git a/lib/catalog/lunaris-text.test.tsx b/lib/catalog/lunaris-text.test.tsx new file mode 100644 index 0000000..287b04e --- /dev/null +++ b/lib/catalog/lunaris-text.test.tsx @@ -0,0 +1,45 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { LunarisText } from "@/components/public/lunaris-text"; +import type { CatalogTextNode } from "@/lib/catalog/lunaris"; + +describe("LunarisText", () => { + it("renders source colors and an accessible interactive term", () => { + const nodes: CatalogTextNode[] = [ + { + type: "link", + target: "N11500003", + detail: { + name: "Marvelous Splendor", + description: "First line\nSecond line", + }, + children: [{ type: "text", value: "Marvelous Splendor" }], + }, + { type: "text", value: " deals " }, + { + type: "color", + value: "#99FFFFFF", + children: [{ type: "text", value: "Cryo AoE DMG" }], + }, + ]; + + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Marvelous Splendor"); + expect(html).toContain("aria-label=\"เปิดรายละเอียด Marvelous Splendor\""); + expect(html).toContain("hover:bg-primary/15"); + expect(html).toContain("hover:decoration-2 hover:decoration-primary"); + expect(html).toContain("color:#99FFFFFF"); + expect(html).toContain("Cryo AoE DMG"); + expect(html).not.toContain("fallback"); + }); + + it("uses plain text for catalogs without structured nodes", () => { + expect(renderToStaticMarkup( + , + )).toContain("Legacy detail"); + }); +}); diff --git a/lib/catalog/lunaris.test.ts b/lib/catalog/lunaris.test.ts index b6c1a03..e040a7b 100644 --- a/lib/catalog/lunaris.test.ts +++ b/lib/catalog/lunaris.test.ts @@ -8,6 +8,7 @@ import { characterDetailSchema, cleanLunarisText, constellationSnapshot, + parseLunarisText, nameCardImageName, splashImageName, supportsWeaponType, @@ -41,6 +42,58 @@ describe("Lunaris character data", () => { expect(cleanLunarisText("First line\\n\\nSecond line")).toBe("First line\n\nSecond line"); }); + it("parses Lunaris links and colors into safe display nodes", () => { + const links = new Map([["N11500003", { + name: "Marvelous Splendor", + description: "A special stack effect.", + }]]); + + expect(parseLunarisText( + "Gain {LINK#N11500003}Marvelous Splendor{/LINK} and deal Cryo AoE DMG.", + links, + )).toEqual([ + { type: "text", value: "Gain " }, + { + type: "link", + target: "N11500003", + detail: { name: "Marvelous Splendor", description: "A special stack effect." }, + children: [{ type: "text", value: "Marvelous Splendor" }], + }, + { type: "text", value: " and deal " }, + { + type: "color", + value: "#99FFFFFF", + children: [{ type: "text", value: "Cryo AoE DMG" }], + }, + { type: "text", value: "." }, + ]); + }); + + it("resolves named skill links and ignores malformed display tags", () => { + const skills = new Map([["Slow Dance", { + name: "Slow Dance", + description: "Skill detail", + }]]); + + expect(parseLunarisText( + "{LINK#S11502}Slow Dance{/LINK}\nSafe", + new Map(), + skills, + )).toEqual([ + { + type: "link", + target: "S11502", + detail: { name: "Slow Dance", description: "Skill detail" }, + children: [{ + type: "color", + value: "#FFD780FF", + children: [{ type: "text", value: "Slow Dance" }], + }], + }, + { type: "text", value: "\nSafe" }, + ]); + }); + it("normalizes constellation levels and icon keys", () => { const result = constellationSnapshot({ constellations: { @@ -51,6 +104,7 @@ describe("Lunaris character data", () => { level: 1, name: "First", description: "Detail", + descriptionNodes: [{ type: "text", value: "Detail" }], iconKey: "catalog/1/constellations/UI_Talent_Test_01.webp", }]); }); diff --git a/lib/catalog/lunaris.ts b/lib/catalog/lunaris.ts index ed39746..be2eaa8 100644 --- a/lib/catalog/lunaris.ts +++ b/lib/catalog/lunaris.ts @@ -4,9 +4,25 @@ export interface CatalogConstellation { level: number; name: string; description: string; + descriptionNodes?: CatalogTextNode[]; iconKey: string | null; } +export interface CatalogTextDetail { + name: string; + description: string; +} + +export type CatalogTextNode = + | { type: "text"; value: string } + | { type: "color"; value: string; children: CatalogTextNode[] } + | { + type: "link"; + target: string; + detail: CatalogTextDetail | null; + children: CatalogTextNode[]; + }; + export interface CatalogTalent { type: "NA" | "E" | "Q"; name: string; @@ -52,6 +68,12 @@ const skillSchema = z.object({ icon: z.string(), }); +const hyperlinkSchema = z.object({ + id: z.number().int(), + name: z.string(), + description: z.string(), +}); + const attributeSchema = z.record(z.string(), z.union([z.number(), z.string()])); const levelingSchema = z.record(z.string(), z.record(z.string(), z.number().int())); @@ -62,6 +84,7 @@ export const characterDetailSchema = z.object({ attributes: z.array(attributeSchema).nullish(), }).nullish(), constellations: z.record(z.string(), constellationSchema).nullish(), + hyperlinks: z.array(hyperlinkSchema).nullish(), skills: z.object({ normalattack: skillSchema.optional(), elementalskill: skillSchema.optional(), @@ -85,6 +108,79 @@ export function cleanLunarisText(value: string): string { .trim(); } +const lunarisTagPattern = /(\{LINK#[^}]+\}|\{\/LINK\}||<\/color>|<[^>]+>)/giu; + +function textFromNodes(nodes: CatalogTextNode[]): string { + return nodes.map((node) => + node.type === "text" ? node.value : textFromNodes(node.children) + ).join(""); +} + +export function parseLunarisText( + value: string, + linkDetails: ReadonlyMap = new Map(), + namedDetails: ReadonlyMap = new Map(), +): CatalogTextNode[] { + const appendText = (children: CatalogTextNode[], text: string) => { + const previous = children.at(-1); + if (previous?.type === "text") previous.value += text; + else children.push({ type: "text", value: text }); + }; + const root: CatalogTextNode[] = []; + const stack: Array<{ + type: "root" | "color" | "link"; + children: CatalogTextNode[]; + node?: Extract; + }> = [{ type: "root", children: root }]; + const source = value + .replace(/\\n/gu, "\n") + .replace(/^"|"$/gu, ""); + let offset = 0; + + for (const match of source.matchAll(lunarisTagPattern)) { + const index = match.index; + const current = stack.at(-1)!; + if (index > offset) { + appendText(current.children, source.slice(offset, index)); + } + + const tag = match[0]; + const link = /^\{LINK#([^}]+)\}$/iu.exec(tag); + const color = /^$/iu.exec(tag); + if (link) { + const node: Extract = { + type: "link", + target: link[1], + detail: linkDetails.get(link[1]) ?? null, + children: [], + }; + current.children.push(node); + stack.push({ type: "link", children: node.children, node }); + } else if (color) { + const node: Extract = { + type: "color", + value: color[1], + children: [], + }; + current.children.push(node); + stack.push({ type: "color", children: node.children, node }); + } else if (tag.toUpperCase() === "{/LINK}" && stack.at(-1)?.type === "link") { + const frame = stack.pop(); + if (frame?.node?.type === "link" && !frame.node.detail) { + frame.node.detail = namedDetails.get(textFromNodes(frame.children).trim()) ?? null; + } + } else if (tag.toLowerCase() === "" && stack.at(-1)?.type === "color") { + stack.pop(); + } + offset = index + tag.length; + } + + if (offset < source.length) { + appendText(stack.at(-1)!.children, source.slice(offset)); + } + return root; +} + export function avatarIconName(value: string): string { return value.replace(/^UI_Gacha_AvatarIcon_/u, "UI_AvatarIcon_"); } @@ -113,6 +209,28 @@ export function constellationSnapshot( iconKey: (icon: string) => string, ): CatalogConstellation[] { const result: CatalogConstellation[] = []; + const linkDetails = new Map( + (value.hyperlinks ?? []).map((item) => [ + `N${item.id}`, + { + name: cleanLunarisText(item.name), + description: cleanLunarisText(item.description), + }, + ]), + ); + const namedDetails = new Map( + [ + value.skills?.normalattack, + value.skills?.elementalskill, + value.skills?.elementalburst, + ].flatMap((skill) => skill ? [[ + cleanLunarisText(skill.name), + { + name: cleanLunarisText(skill.name), + description: cleanLunarisText(skill.description), + }, + ] as const] : []), + ); for (const index of Array.from({ length: 6 }, (_, itemIndex) => itemIndex)) { const level = index + 1; const item = value.constellations?.[`const${level}`]; @@ -121,6 +239,7 @@ export function constellationSnapshot( level, name: cleanLunarisText(item.name), description: cleanLunarisText(item.description), + descriptionNodes: parseLunarisText(item.description, linkDetails, namedDetails), iconKey: iconKey(item.icon), }); } diff --git a/lib/catalog/sync.ts b/lib/catalog/sync.ts index 61fd784..72a8f3a 100644 --- a/lib/catalog/sync.ts +++ b/lib/catalog/sync.ts @@ -33,7 +33,8 @@ import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers"; import { withCatalogSyncLock } from "@/lib/catalog/sync-lock"; import { getRedisClient, redisEventPrefix } from "@/lib/redis/client"; import { - compareLunarisVersions, + catalogFormatVersion, + catalogSnapshotIsCurrent, lunarisVersionSchema, } from "@/lib/catalog/version"; import { getMediaStorage } from "@/lib/media/storage"; @@ -53,7 +54,10 @@ const versionSchema = z.object({ version: lunarisVersionSchema, timestamp: z.number().int().optional(), }); -const currentVersionSchema = z.object({ version: lunarisVersionSchema }); +const currentVersionSchema = z.object({ + version: lunarisVersionSchema, + formatVersion: z.number().int().optional().default(1), +}); const characterSchema = z.record( z.string(), z.object({ @@ -636,7 +640,10 @@ async function syncCatalogVersion(version: string, control?: SyncControl): Promi ); }); await control?.report("database", 1, 1, "บันทึก Catalog แล้ว"); - await storage.write("catalog/current.json", JSON.stringify({ version }), { + await storage.write("catalog/current.json", JSON.stringify({ + version, + formatVersion: catalogFormatVersion, + }), { type: "application/json", acl: "public-read", }); @@ -717,10 +724,14 @@ async function executeCatalogSyncJob(initial: CatalogSyncJob) { } export async function getCurrentCatalogVersion(): Promise { + return (await getCurrentCatalogState())?.version ?? null; +} + +async function getCurrentCatalogState() { const storage = await getMediaStorage(); const current = storage.file("catalog/current.json"); if (!(await current.exists())) return null; - return currentVersionSchema.parse(await current.json()).version; + return currentVersionSchema.parse(await current.json()); } async function hasCatalogRecords(): Promise { @@ -743,10 +754,13 @@ export async function processCatalogSync( ); return withCatalogSyncLock(async () => { - const currentVersion = await getCurrentCatalogVersion(); + const current = await getCurrentCatalogState(); if ( - currentVersion && - compareLunarisVersions(version, currentVersion) <= 0 && + catalogSnapshotIsCurrent( + version, + current?.version ?? null, + current?.formatVersion ?? null, + ) && await hasCatalogRecords() ) { return "unchanged"; diff --git a/lib/catalog/version.test.ts b/lib/catalog/version.test.ts index cceee25..b18988a 100644 --- a/lib/catalog/version.test.ts +++ b/lib/catalog/version.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + catalogFormatVersion, + catalogSnapshotIsCurrent, compareLunarisVersions, parseLunarisVersionChange, } from "./version"; @@ -29,4 +31,10 @@ Odette`), expect(compareLunarisVersions("7.0.54", "7.0.54.0")).toBe(0); expect(compareLunarisVersions("7.0.53.2", "7.0.54")).toBe(-1); }); + + it("refreshes a same-version catalog when its local format is outdated", () => { + expect(catalogSnapshotIsCurrent("7.0.54", "7.0.54", null)).toBe(false); + expect(catalogSnapshotIsCurrent("7.0.54", "7.0.54", catalogFormatVersion - 1)).toBe(false); + expect(catalogSnapshotIsCurrent("7.0.54", "7.0.54", catalogFormatVersion)).toBe(true); + }); }); diff --git a/lib/catalog/version.ts b/lib/catalog/version.ts index 2cfb375..9d5af62 100644 --- a/lib/catalog/version.ts +++ b/lib/catalog/version.ts @@ -1,6 +1,7 @@ import * as z from "zod"; export const lunarisVersionSchema = z.string().regex(/^\d+(?:\.\d+)+$/u); +export const catalogFormatVersion = 2; export interface LunarisVersionChange { previousVersion: string; @@ -31,3 +32,15 @@ export function compareLunarisVersions(left: string, right: string): number { } return 0; } + +export function catalogSnapshotIsCurrent( + requestedVersion: string, + currentVersion: string | null, + currentFormatVersion: number | null, +): boolean { + return Boolean( + currentVersion && + compareLunarisVersions(requestedVersion, currentVersion) <= 0 && + currentFormatVersion === catalogFormatVersion + ); +} diff --git a/lib/guides/queries.ts b/lib/guides/queries.ts index b843c3a..3946315 100644 --- a/lib/guides/queries.ts +++ b/lib/guides/queries.ts @@ -472,6 +472,7 @@ async function getGuideContent(slug: string, publicOnly: boolean, selectedSectio ...recommendation, name: catalog?.name ?? `C${recommendation.level}`, detail: catalog?.description ?? recommendation.detail, + detailNodes: catalog?.descriptionNodes, iconUrl: catalog?.iconKey ? publicMediaUrl(catalog.iconKey) : null, }; }),