feat : Con more reaible
This commit is contained in:
@@ -57,8 +57,10 @@ import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation
|
|||||||
import { DeleteConditionDialog } from "@/components/admin/delete-condition-dialog";
|
import { DeleteConditionDialog } from "@/components/admin/delete-condition-dialog";
|
||||||
import { MediaUploader } from "@/components/admin/media-uploader";
|
import { MediaUploader } from "@/components/admin/media-uploader";
|
||||||
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
||||||
|
import { LunarisText } from "@/components/public/lunaris-text";
|
||||||
import { TeamArtifactIcon } from "@/components/public/team-search";
|
import { TeamArtifactIcon } from "@/components/public/team-search";
|
||||||
import { SimpleTooltip } from "@/components/tooltip";
|
import { SimpleTooltip } from "@/components/tooltip";
|
||||||
|
import type { CatalogTextNode } from "@/lib/catalog/lunaris";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
@@ -299,6 +301,7 @@ type CatalogConstellation = {
|
|||||||
level: number;
|
level: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
descriptionNodes?: CatalogTextNode[];
|
||||||
iconUrl: string | null;
|
iconUrl: string | null;
|
||||||
};
|
};
|
||||||
type CatalogTalent = {
|
type CatalogTalent = {
|
||||||
@@ -2474,6 +2477,7 @@ function ConstellationEditor({
|
|||||||
name: `C${index + 1}`,
|
name: `C${index + 1}`,
|
||||||
description:
|
description:
|
||||||
savedRows.find((row) => row.level === index + 1)?.detail ?? "",
|
savedRows.find((row) => row.level === index + 1)?.detail ?? "",
|
||||||
|
descriptionNodes: undefined,
|
||||||
iconUrl: null,
|
iconUrl: null,
|
||||||
}));
|
}));
|
||||||
return source.map((constellation) => {
|
return source.map((constellation) => {
|
||||||
@@ -2643,9 +2647,11 @@ function ConstellationEditor({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
C{row.level} · {row.name}
|
C{row.level} - {row.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{row.description}</CardDescription>
|
<CardDescription className="whitespace-pre-line">
|
||||||
|
<LunarisText nodes={row.descriptionNodes} fallback={row.description} />
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex items-end gap-3">
|
<CardContent className="flex items-end gap-3">
|
||||||
<Field className="min-w-0 flex-1">
|
<Field className="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -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 <Fragment key={index}>{node.value}</Fragment>;
|
||||||
|
const children = renderNodes(node.children);
|
||||||
|
if (node.type === "color") {
|
||||||
|
return <span key={index} style={{ color: node.value }}>{children}</span>;
|
||||||
|
}
|
||||||
|
if (!node.detail) return <Fragment key={index}>{children}</Fragment>;
|
||||||
|
return (
|
||||||
|
<TermDetail
|
||||||
|
key={index}
|
||||||
|
label={children}
|
||||||
|
title={node.detail.name}
|
||||||
|
detail={node.detail.description}
|
||||||
|
showIcon={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LunarisText({
|
||||||
|
nodes,
|
||||||
|
fallback,
|
||||||
|
}: {
|
||||||
|
nodes?: CatalogTextNode[];
|
||||||
|
fallback: string;
|
||||||
|
}) {
|
||||||
|
return <>{nodes?.length ? renderNodes(nodes) : fallback}</>;
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ import { WeaponDisplayMode, WeaponDisplayModeProvider } from "@/components/publi
|
|||||||
import { ArtifactImageExport } from "@/components/public/artifact-image-export";
|
import { ArtifactImageExport } from "@/components/public/artifact-image-export";
|
||||||
import { GuideExportBranding } from "@/components/public/guide-export-branding";
|
import { GuideExportBranding } from "@/components/public/guide-export-branding";
|
||||||
import { ConstellationImage } from "@/components/public/constellation-image";
|
import { ConstellationImage } from "@/components/public/constellation-image";
|
||||||
|
import { LunarisText } from "@/components/public/lunaris-text";
|
||||||
import { ConditionSwitcher } from "@/components/public/condition-switcher";
|
import { ConditionSwitcher } from "@/components/public/condition-switcher";
|
||||||
import { MaterialPoster } from "@/components/public/material-poster";
|
import { MaterialPoster } from "@/components/public/material-poster";
|
||||||
import { RichTextContent } from "@/components/rich-text-content";
|
import { RichTextContent } from "@/components/rich-text-content";
|
||||||
@@ -1226,12 +1227,14 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
|
|||||||
{metric(row.cumulativePercent)}
|
{metric(row.cumulativePercent)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-base font-medium text-white">
|
<CardDescription className="text-base font-medium text-white">
|
||||||
C{row.level} · {row.name}
|
C{row.level} - {row.name}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="whitespace-pre-line leading-7">{row.detail}</p>
|
<div className="whitespace-pre-line leading-7">
|
||||||
|
<LunarisText nodes={row.detailNodes} fallback={row.detail} />
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { InfoIcon } from "lucide-react";
|
import { InfoIcon } from "lucide-react";
|
||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore, type ReactNode } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -29,33 +29,37 @@ function getSnapshot() {
|
|||||||
return window.matchMedia(finePointerQuery).matches;
|
return window.matchMedia(finePointerQuery).matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Trigger({ label }: { label: string }) {
|
function Trigger({ label, showIcon }: { label: ReactNode; showIcon: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex min-h-11 cursor-help items-center gap-1 rounded-md text-left font-medium underline decoration-border underline-offset-4 outline-none hover:text-primary focus-visible:ring-2 focus-visible:ring-ring md:min-h-0">
|
<span className="inline-flex min-h-11 cursor-help items-center gap-1 rounded-md text-left font-medium underline decoration-border underline-offset-4 outline-none transition-colors hover:bg-primary/15 hover:text-primary hover:decoration-2 hover:decoration-primary focus-visible:bg-primary/15 focus-visible:text-primary focus-visible:ring-2 focus-visible:ring-ring md:min-h-0">
|
||||||
{label}
|
{label}
|
||||||
<InfoIcon aria-hidden="true" className="size-3.5 text-muted-foreground" />
|
{showIcon ? <InfoIcon aria-hidden="true" className="size-3.5 text-muted-foreground" /> : null}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TermDetail({
|
export function TermDetail({
|
||||||
label,
|
label,
|
||||||
|
title,
|
||||||
detail,
|
detail,
|
||||||
|
showIcon = true,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: ReactNode;
|
||||||
|
title: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
|
showIcon?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const finePointer = useSyncExternalStore(subscribe, getSnapshot, () => false);
|
const finePointer = useSyncExternalStore(subscribe, getSnapshot, () => false);
|
||||||
|
|
||||||
if (finePointer) {
|
if (finePointer) {
|
||||||
return (
|
return (
|
||||||
<HoverCard>
|
<HoverCard>
|
||||||
<HoverCardTrigger aria-label={`ดูรายละเอียด ${label}`}>
|
<HoverCardTrigger aria-label={`ดูรายละเอียด ${title}`}>
|
||||||
<Trigger label={label} />
|
<Trigger label={label} showIcon={showIcon} />
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent align="start">
|
<HoverCardContent align="start">
|
||||||
<p className="font-medium">{label}</p>
|
<p className="font-medium text-primary">{title}</p>
|
||||||
<p className="mt-1 text-muted-foreground">{detail}</p>
|
<p className="mt-1 whitespace-pre-line text-muted-foreground">{detail}</p>
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
);
|
);
|
||||||
@@ -63,13 +67,13 @@ export function TermDetail({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer showSwipeHandle>
|
<Drawer showSwipeHandle>
|
||||||
<DrawerTrigger aria-label={`เปิดรายละเอียด ${label}`}>
|
<DrawerTrigger aria-label={`เปิดรายละเอียด ${title}`}>
|
||||||
<Trigger label={label} />
|
<Trigger label={label} showIcon={showIcon} />
|
||||||
</DrawerTrigger>
|
</DrawerTrigger>
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
<DrawerTitle>{label}</DrawerTitle>
|
<DrawerTitle className="text-primary">{title}</DrawerTitle>
|
||||||
<DrawerDescription>{detail}</DrawerDescription>
|
<DrawerDescription className="whitespace-pre-line text-left">{detail}</DrawerDescription>
|
||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
<div className="h-6" />
|
<div className="h-6" />
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<LunarisText nodes={nodes} fallback="fallback" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<LunarisText fallback="Legacy detail" />,
|
||||||
|
)).toContain("Legacy detail");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
characterDetailSchema,
|
characterDetailSchema,
|
||||||
cleanLunarisText,
|
cleanLunarisText,
|
||||||
constellationSnapshot,
|
constellationSnapshot,
|
||||||
|
parseLunarisText,
|
||||||
nameCardImageName,
|
nameCardImageName,
|
||||||
splashImageName,
|
splashImageName,
|
||||||
supportsWeaponType,
|
supportsWeaponType,
|
||||||
@@ -41,6 +42,58 @@ describe("Lunaris character data", () => {
|
|||||||
expect(cleanLunarisText("First line\\n\\n<i>Second line</i>")).toBe("First line\n\nSecond line");
|
expect(cleanLunarisText("First line\\n\\n<i>Second line</i>")).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 <color=#99FFFFFF>Cryo AoE DMG</color>.",
|
||||||
|
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}<color=#FFD780FF>Slow Dance</color>{/LINK}</color>\n<color=javascript:alert(1)>Safe</color>",
|
||||||
|
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", () => {
|
it("normalizes constellation levels and icon keys", () => {
|
||||||
const result = constellationSnapshot({
|
const result = constellationSnapshot({
|
||||||
constellations: {
|
constellations: {
|
||||||
@@ -51,6 +104,7 @@ describe("Lunaris character data", () => {
|
|||||||
level: 1,
|
level: 1,
|
||||||
name: "First",
|
name: "First",
|
||||||
description: "Detail",
|
description: "Detail",
|
||||||
|
descriptionNodes: [{ type: "text", value: "Detail" }],
|
||||||
iconKey: "catalog/1/constellations/UI_Talent_Test_01.webp",
|
iconKey: "catalog/1/constellations/UI_Talent_Test_01.webp",
|
||||||
}]);
|
}]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,25 @@ export interface CatalogConstellation {
|
|||||||
level: number;
|
level: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
descriptionNodes?: CatalogTextNode[];
|
||||||
iconKey: string | null;
|
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 {
|
export interface CatalogTalent {
|
||||||
type: "NA" | "E" | "Q";
|
type: "NA" | "E" | "Q";
|
||||||
name: string;
|
name: string;
|
||||||
@@ -52,6 +68,12 @@ const skillSchema = z.object({
|
|||||||
icon: z.string(),
|
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 attributeSchema = z.record(z.string(), z.union([z.number(), z.string()]));
|
||||||
const levelingSchema = z.record(z.string(), z.record(z.string(), z.number().int()));
|
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(),
|
attributes: z.array(attributeSchema).nullish(),
|
||||||
}).nullish(),
|
}).nullish(),
|
||||||
constellations: z.record(z.string(), constellationSchema).nullish(),
|
constellations: z.record(z.string(), constellationSchema).nullish(),
|
||||||
|
hyperlinks: z.array(hyperlinkSchema).nullish(),
|
||||||
skills: z.object({
|
skills: z.object({
|
||||||
normalattack: skillSchema.optional(),
|
normalattack: skillSchema.optional(),
|
||||||
elementalskill: skillSchema.optional(),
|
elementalskill: skillSchema.optional(),
|
||||||
@@ -85,6 +108,79 @@ export function cleanLunarisText(value: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lunarisTagPattern = /(\{LINK#[^}]+\}|\{\/LINK\}|<color=#[0-9a-f]{6}(?:[0-9a-f]{2})?>|<\/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<string, CatalogTextDetail> = new Map(),
|
||||||
|
namedDetails: ReadonlyMap<string, CatalogTextDetail> = 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<CatalogTextNode, { type: "color" | "link" }>;
|
||||||
|
}> = [{ 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 = /^<color=(#[0-9a-f]{6}(?:[0-9a-f]{2})?)>$/iu.exec(tag);
|
||||||
|
if (link) {
|
||||||
|
const node: Extract<CatalogTextNode, { type: "link" }> = {
|
||||||
|
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<CatalogTextNode, { type: "color" }> = {
|
||||||
|
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() === "</color>" && 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 {
|
export function avatarIconName(value: string): string {
|
||||||
return value.replace(/^UI_Gacha_AvatarIcon_/u, "UI_AvatarIcon_");
|
return value.replace(/^UI_Gacha_AvatarIcon_/u, "UI_AvatarIcon_");
|
||||||
}
|
}
|
||||||
@@ -113,6 +209,28 @@ export function constellationSnapshot(
|
|||||||
iconKey: (icon: string) => string,
|
iconKey: (icon: string) => string,
|
||||||
): CatalogConstellation[] {
|
): CatalogConstellation[] {
|
||||||
const result: 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)) {
|
for (const index of Array.from({ length: 6 }, (_, itemIndex) => itemIndex)) {
|
||||||
const level = index + 1;
|
const level = index + 1;
|
||||||
const item = value.constellations?.[`const${level}`];
|
const item = value.constellations?.[`const${level}`];
|
||||||
@@ -121,6 +239,7 @@ export function constellationSnapshot(
|
|||||||
level,
|
level,
|
||||||
name: cleanLunarisText(item.name),
|
name: cleanLunarisText(item.name),
|
||||||
description: cleanLunarisText(item.description),
|
description: cleanLunarisText(item.description),
|
||||||
|
descriptionNodes: parseLunarisText(item.description, linkDetails, namedDetails),
|
||||||
iconKey: iconKey(item.icon),
|
iconKey: iconKey(item.icon),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-7
@@ -33,7 +33,8 @@ import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers";
|
|||||||
import { withCatalogSyncLock } from "@/lib/catalog/sync-lock";
|
import { withCatalogSyncLock } from "@/lib/catalog/sync-lock";
|
||||||
import { getRedisClient, redisEventPrefix } from "@/lib/redis/client";
|
import { getRedisClient, redisEventPrefix } from "@/lib/redis/client";
|
||||||
import {
|
import {
|
||||||
compareLunarisVersions,
|
catalogFormatVersion,
|
||||||
|
catalogSnapshotIsCurrent,
|
||||||
lunarisVersionSchema,
|
lunarisVersionSchema,
|
||||||
} from "@/lib/catalog/version";
|
} from "@/lib/catalog/version";
|
||||||
import { getMediaStorage } from "@/lib/media/storage";
|
import { getMediaStorage } from "@/lib/media/storage";
|
||||||
@@ -53,7 +54,10 @@ const versionSchema = z.object({
|
|||||||
version: lunarisVersionSchema,
|
version: lunarisVersionSchema,
|
||||||
timestamp: z.number().int().optional(),
|
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(
|
const characterSchema = z.record(
|
||||||
z.string(),
|
z.string(),
|
||||||
z.object({
|
z.object({
|
||||||
@@ -636,7 +640,10 @@ async function syncCatalogVersion(version: string, control?: SyncControl): Promi
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
await control?.report("database", 1, 1, "บันทึก Catalog แล้ว");
|
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",
|
type: "application/json",
|
||||||
acl: "public-read",
|
acl: "public-read",
|
||||||
});
|
});
|
||||||
@@ -717,10 +724,14 @@ async function executeCatalogSyncJob(initial: CatalogSyncJob) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCurrentCatalogVersion(): Promise<string | null> {
|
export async function getCurrentCatalogVersion(): Promise<string | null> {
|
||||||
|
return (await getCurrentCatalogState())?.version ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentCatalogState() {
|
||||||
const storage = await getMediaStorage();
|
const storage = await getMediaStorage();
|
||||||
const current = storage.file("catalog/current.json");
|
const current = storage.file("catalog/current.json");
|
||||||
if (!(await current.exists())) return null;
|
if (!(await current.exists())) return null;
|
||||||
return currentVersionSchema.parse(await current.json()).version;
|
return currentVersionSchema.parse(await current.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hasCatalogRecords(): Promise<boolean> {
|
async function hasCatalogRecords(): Promise<boolean> {
|
||||||
@@ -743,10 +754,13 @@ export async function processCatalogSync(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return withCatalogSyncLock(async () => {
|
return withCatalogSyncLock(async () => {
|
||||||
const currentVersion = await getCurrentCatalogVersion();
|
const current = await getCurrentCatalogState();
|
||||||
if (
|
if (
|
||||||
currentVersion &&
|
catalogSnapshotIsCurrent(
|
||||||
compareLunarisVersions(version, currentVersion) <= 0 &&
|
version,
|
||||||
|
current?.version ?? null,
|
||||||
|
current?.formatVersion ?? null,
|
||||||
|
) &&
|
||||||
await hasCatalogRecords()
|
await hasCatalogRecords()
|
||||||
) {
|
) {
|
||||||
return "unchanged";
|
return "unchanged";
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
catalogFormatVersion,
|
||||||
|
catalogSnapshotIsCurrent,
|
||||||
compareLunarisVersions,
|
compareLunarisVersions,
|
||||||
parseLunarisVersionChange,
|
parseLunarisVersionChange,
|
||||||
} from "./version";
|
} from "./version";
|
||||||
@@ -29,4 +31,10 @@ Odette`),
|
|||||||
expect(compareLunarisVersions("7.0.54", "7.0.54.0")).toBe(0);
|
expect(compareLunarisVersions("7.0.54", "7.0.54.0")).toBe(0);
|
||||||
expect(compareLunarisVersions("7.0.53.2", "7.0.54")).toBe(-1);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
||||||
export const lunarisVersionSchema = z.string().regex(/^\d+(?:\.\d+)+$/u);
|
export const lunarisVersionSchema = z.string().regex(/^\d+(?:\.\d+)+$/u);
|
||||||
|
export const catalogFormatVersion = 2;
|
||||||
|
|
||||||
export interface LunarisVersionChange {
|
export interface LunarisVersionChange {
|
||||||
previousVersion: string;
|
previousVersion: string;
|
||||||
@@ -31,3 +32,15 @@ export function compareLunarisVersions(left: string, right: string): number {
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function catalogSnapshotIsCurrent(
|
||||||
|
requestedVersion: string,
|
||||||
|
currentVersion: string | null,
|
||||||
|
currentFormatVersion: number | null,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(
|
||||||
|
currentVersion &&
|
||||||
|
compareLunarisVersions(requestedVersion, currentVersion) <= 0 &&
|
||||||
|
currentFormatVersion === catalogFormatVersion
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -472,6 +472,7 @@ async function getGuideContent(slug: string, publicOnly: boolean, selectedSectio
|
|||||||
...recommendation,
|
...recommendation,
|
||||||
name: catalog?.name ?? `C${recommendation.level}`,
|
name: catalog?.name ?? `C${recommendation.level}`,
|
||||||
detail: catalog?.description ?? recommendation.detail,
|
detail: catalog?.description ?? recommendation.detail,
|
||||||
|
detailNodes: catalog?.descriptionNodes,
|
||||||
iconUrl: catalog?.iconKey ? publicMediaUrl(catalog.iconKey) : null,
|
iconUrl: catalog?.iconKey ? publicMediaUrl(catalog.iconKey) : null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user