feat : update
CI / Verify (push) Successful in 1m5s
CI / Build immutable images and deploy (push) Successful in 2m2s

This commit is contained in:
2026-09-04 22:02:57 +07:00 Unverified
parent 513e37ad83
commit 5f74ce1de6
10 changed files with 3833 additions and 44 deletions
+1 -1
View File
@@ -22,5 +22,5 @@ export default async function StructuredEditorPage({ params }: { params: Promise
const [catalog, content] = await Promise.all([getActiveCatalog(), getAdminGuideContent(guide.slug)]); const [catalog, content] = await Promise.all([getActiveCatalog(), getAdminGuideContent(guide.slug)]);
if (!catalog || !content) notFound(); if (!catalog || !content) notFound();
const guideCharacter = catalog.characters.find((item) => item.key === guide.characterKey); const guideCharacter = catalog.characters.find((item) => item.key === guide.characterKey);
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 className="min-w-0"><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="whitespace-nowrap font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ sectionNote: guide.sections.find((section) => section.slug === page)?.note ?? null, weaponGroups: content.weaponGroups, 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, note: row.note })) })), artifactProfile: content.artifactProfile, constellationGroups: content.constellationGroups.map((group) => ({ id: group.id, note: group.note, rows: group.rows })), teams: content.teams.map((team) => ({ id: team.id, title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, secondArtifactKey: member.secondArtifactKey, constellation: member.constellation, refinement: member.refinement, 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, imageWidth: row.imageWidth, imageHeight: row.imageHeight })) })) }} /></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 className="min-w-0"><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="whitespace-nowrap font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ sectionNote: guide.sections.find((section) => section.slug === page)?.note ?? null, weaponGroups: content.weaponGroups, 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, note: row.note })) })), artifactProfile: content.artifactProfile, constellationGroups: content.constellationGroups.map((group) => ({ id: group.id, note: group.note, rows: group.rows })), teams: content.teams.map((team) => ({ id: team.id, title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, secondArtifactKey: member.secondArtifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ id: section.id, title: section.title, rows: section.rows.map((row) => ({ id: row.id, mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent, backgroundColor: row.backgroundColor, textColor: row.textColor, imageUrl: row.imageUrl, imageWidth: row.imageWidth, imageHeight: row.imageHeight })) })) }} /></main></div>;
} }
+12
View File
@@ -130,6 +130,11 @@
} }
} }
::view-transition-group(.guide-nav-pill) {
animation-duration: 220ms;
animation-timing-function: ease-in-out;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
@@ -139,4 +144,11 @@
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
::view-transition-old(*),
::view-transition-new(*),
::view-transition-group(*) {
animation-delay: 0s !important;
animation-duration: 0.01ms !important;
}
} }
+273 -11
View File
@@ -259,6 +259,8 @@ type TeamValue = {
}; };
let clientIdCounter = 0; let clientIdCounter = 0;
const DEFAULT_EXTRA_BACKGROUND_COLOR = "#24232C";
const DEFAULT_EXTRA_TEXT_COLOR = "#F5F2EA";
function createClientId(prefix: string): string { function createClientId(prefix: string): string {
const browserCrypto = globalThis.crypto; const browserCrypto = globalThis.crypto;
@@ -303,12 +305,16 @@ export interface GuideSectionEditorProps {
constellationGroups: ConstellationGroupValue[]; constellationGroups: ConstellationGroupValue[];
teams: TeamValue[]; teams: TeamValue[];
extras: Array<{ extras: Array<{
id: string;
title: string; title: string;
rows: Array<{ rows: Array<{
id: string;
mediaId: string | null; mediaId: string | null;
heading: string; heading: string;
body: string; body: string;
percent: string | null; percent: string | null;
backgroundColor: string | null;
textColor: string | null;
imageUrl: string | null; imageUrl: string | null;
imageWidth: number | null; imageWidth: number | null;
imageHeight: number | null; imageHeight: number | null;
@@ -769,6 +775,46 @@ function SortableTeamMember({
); );
} }
function SortableExtraItem({
id,
label,
children,
}: {
id: string;
label: string;
children: (dragHandle: ReactNode) => ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(isDragging && "opacity-60")}
>
{children(
<Button
type="button"
variant="ghost"
size="icon-sm"
className="touch-none"
{...attributes}
{...listeners}
>
<GripVerticalIcon />
<span className="sr-only">{label}</span>
</Button>,
)}
</div>
);
}
export function GuideSectionEditor(props: GuideSectionEditorProps) { export function GuideSectionEditor(props: GuideSectionEditorProps) {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -3250,7 +3296,22 @@ function TeamEditor({
} }
function ExtraEditor({ guide, initial }: EditorBase) { function ExtraEditor({ guide, initial }: EditorBase) {
const [sections, setSections] = useState(initial.extras); const extraSensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const [sections, setSections] = useState(() =>
initial.extras.map((section) => ({
...section,
clientId: section.id,
rows: section.rows.map((row) => ({
...row,
clientId: row.id,
})),
})),
);
const value = { const value = {
sections: sections.map((section) => ({ sections: sections.map((section) => ({
title: section.title, title: section.title,
@@ -3262,6 +3323,8 @@ function ExtraEditor({ guide, initial }: EditorBase) {
row.percent === null || row.percent === "" row.percent === null || row.percent === ""
? null ? null
: Number(row.percent), : Number(row.percent),
backgroundColor: row.backgroundColor,
textColor: row.textColor,
})), })),
})), })),
}; };
@@ -3282,8 +3345,40 @@ function ExtraEditor({ guide, initial }: EditorBase) {
localDraft, localDraft,
parseLocalDraft: (candidate) => parseLocalDraft: (candidate) =>
parseEditorDraft<typeof localDraft>(candidate, ["sections"]), parseEditorDraft<typeof localDraft>(candidate, ["sections"]),
restoreLocalDraft: (draft) => setSections(draft.sections), restoreLocalDraft: (draft) => setSections(
draft.sections.map((section) => ({
...section,
id: section.id || createClientId("extra-section-record"),
clientId: section.clientId || createClientId("extra-section"),
rows: section.rows.map((row) => ({
...row,
id: row.id || createClientId("extra-row-record"),
backgroundColor: row.backgroundColor ?? null,
textColor: row.textColor ?? null,
clientId: row.clientId || createClientId("extra-row"),
})),
})),
),
}); });
const handleSectionDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
setSections((current) => {
const from = current.findIndex((section) => section.clientId === active.id);
const to = current.findIndex((section) => section.clientId === over.id);
return from === -1 || to === -1 ? current : arrayMove(current, from, to);
});
};
const handleRowDragEnd = (sectionId: string, { active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
setSections((current) => current.map((section) => {
if (section.clientId !== sectionId) return section;
const from = section.rows.findIndex((row) => row.clientId === active.id);
const to = section.rows.findIndex((row) => row.clientId === over.id);
return from === -1 || to === -1
? section
: { ...section, rows: arrayMove(section.rows, from, to) };
}));
};
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -3294,9 +3389,23 @@ function ExtraEditor({ guide, initial }: EditorBase) {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-5"> <CardContent className="flex flex-col gap-5">
{sections.map((section, sectionIndex) => ( <DndContext
sensors={extraSensors}
collisionDetection={closestCenter}
onDragEnd={handleSectionDragEnd}
>
<SortableContext
items={sections.map((section) => section.clientId)}
strategy={verticalListSortingStrategy}
>
{sections.map((section, sectionIndex) => (
<SortableExtraItem
key={section.clientId}
id={section.clientId}
label={`ลากเพื่อจัดลำดับ ${section.title || `Extra section ${sectionIndex + 1}`}`}
>
{(sectionDragHandle) => (
<Card <Card
key={sectionIndex}
size="sm" size="sm"
className={cn( className={cn(
extraIssue("sections", sectionIndex, "title") && "border-destructive", extraIssue("sections", sectionIndex, "title") && "border-destructive",
@@ -3319,7 +3428,8 @@ function ExtraEditor({ guide, initial }: EditorBase) {
} }
/> />
</CardTitle> </CardTitle>
<CardAction> <CardAction className="flex items-center gap-1">
{sectionDragHandle}
<DeleteConfirmationDialog <DeleteConfirmationDialog
trigger={ trigger={
<Button type="button" variant="ghost" size="icon-sm" /> <Button type="button" variant="ghost" size="icon-sm" />
@@ -3338,9 +3448,23 @@ function ExtraEditor({ guide, initial }: EditorBase) {
</CardAction> </CardAction>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
{section.rows.map((row, rowIndex) => ( <DndContext
sensors={extraSensors}
collisionDetection={closestCenter}
onDragEnd={(event) => handleRowDragEnd(section.clientId, event)}
>
<SortableContext
items={section.rows.map((row) => row.clientId)}
strategy={verticalListSortingStrategy}
>
{section.rows.map((row, rowIndex) => (
<SortableExtraItem
key={row.clientId}
id={row.clientId}
label={`ลากเพื่อจัดลำดับ ${row.heading || `แถว ${rowIndex + 1}`}`}
>
{(rowDragHandle) => (
<Card <Card
key={rowIndex}
size="sm" size="sm"
className={cn( className={cn(
extraIssue("sections", sectionIndex, "rows", rowIndex) && extraIssue("sections", sectionIndex, "rows", rowIndex) &&
@@ -3351,7 +3475,8 @@ function ExtraEditor({ guide, initial }: EditorBase) {
<CardTitle> <CardTitle>
{row.heading || `แถว ${rowIndex + 1}`} {row.heading || `แถว ${rowIndex + 1}`}
</CardTitle> </CardTitle>
<CardAction> <CardAction className="flex items-center gap-1">
{rowDragHandle}
<DeleteConfirmationDialog <DeleteConfirmationDialog
trigger={ trigger={
<Button <Button
@@ -3575,10 +3700,131 @@ function ExtraEditor({ guide, initial }: EditorBase) {
} }
/> />
</Field> </Field>
<FieldSet>
<FieldLegend>สีการ์ด (optional)</FieldLegend>
<FieldDescription>
สีจะใช้เฉพาะการ์ดนี้บนหน้า Guide
</FieldDescription>
<FieldGroup className="sm:grid sm:grid-cols-2">
<Field
data-invalid={extraIssue(
"sections",
sectionIndex,
"rows",
rowIndex,
"backgroundColor",
) || undefined}
>
<FieldLabel>สีพื้นหลัง</FieldLabel>
<Input
type="color"
value={row.backgroundColor ?? DEFAULT_EXTRA_BACKGROUND_COLOR}
aria-invalid={extraIssue(
"sections",
sectionIndex,
"rows",
rowIndex,
"backgroundColor",
)}
onChange={(event) =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map((candidate, inner) =>
inner === rowIndex
? {
...candidate,
backgroundColor: event.target.value,
textColor: candidate.textColor ?? DEFAULT_EXTRA_TEXT_COLOR,
}
: candidate,
),
}
: item,
),
)
}
/>
</Field>
<Field
data-invalid={extraIssue(
"sections",
sectionIndex,
"rows",
rowIndex,
"textColor",
) || undefined}
>
<FieldLabel>สีข้อความ</FieldLabel>
<Input
type="color"
value={row.textColor ?? DEFAULT_EXTRA_TEXT_COLOR}
aria-invalid={extraIssue(
"sections",
sectionIndex,
"rows",
rowIndex,
"textColor",
)}
onChange={(event) =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map((candidate, inner) =>
inner === rowIndex
? {
...candidate,
backgroundColor: candidate.backgroundColor ?? DEFAULT_EXTRA_BACKGROUND_COLOR,
textColor: event.target.value,
}
: candidate,
),
}
: item,
),
)
}
/>
</Field>
</FieldGroup>
<Button
type="button"
variant="outline"
size="sm"
disabled={row.backgroundColor === null && row.textColor === null}
onClick={() =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map((candidate, inner) =>
inner === rowIndex
? { ...candidate, backgroundColor: null, textColor: null }
: candidate,
),
}
: item,
),
)
}
>
<RefreshCcwIcon data-icon="inline-start" />
ใช้สีตามธีม
</Button>
</FieldSet>
</FieldGroup> </FieldGroup>
</CardContent> </CardContent>
</Card> </Card>
))} )}
</SortableExtraItem>
))}
</SortableContext>
</DndContext>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -3591,6 +3837,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
rows: [ rows: [
...item.rows, ...item.rows,
{ {
id: createClientId("extra-row-record"),
mediaId: null, mediaId: null,
imageUrl: null, imageUrl: null,
imageWidth: null, imageWidth: null,
@@ -3598,6 +3845,9 @@ function ExtraEditor({ guide, initial }: EditorBase) {
heading: "", heading: "",
body: "", body: "",
percent: null, percent: null,
backgroundColor: null,
textColor: null,
clientId: createClientId("extra-row"),
}, },
], ],
} }
@@ -3611,13 +3861,25 @@ function ExtraEditor({ guide, initial }: EditorBase) {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
))} )}
</SortableExtraItem>
))}
</SortableContext>
</DndContext>
</CardContent> </CardContent>
<CardFooter className="flex-wrap justify-between gap-3"> <CardFooter className="flex-wrap justify-between gap-3">
<Button <Button
variant="outline" variant="outline"
onClick={() => onClick={() =>
setSections([...sections, { title: "Extra", rows: [] }]) setSections([
...sections,
{
id: createClientId("extra-section-record"),
title: "Extra",
rows: [],
clientId: createClientId("extra-section"),
},
])
} }
> >
<PlusIcon data-icon="inline-start" /> <PlusIcon data-icon="inline-start" />
+65 -25
View File
@@ -1,6 +1,7 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { ArrowUpRightIcon, ChevronLeftIcon } from "lucide-react"; import { ArrowUpRightIcon, ChevronLeftIcon } from "lucide-react";
import { ViewTransition } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -124,44 +125,66 @@ export function StructuredGuideShell({
<Link <Link
href={`/${guide.slug}`} href={`/${guide.slug}`}
className={cn( className={cn(
"flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring", "relative flex min-h-8 shrink-0 items-center whitespace-nowrap rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
!section !section
? "bg-muted text-foreground" ? "text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground", : "text-muted-foreground hover:bg-muted hover:text-foreground",
)} )}
prefetch prefetch
> >
<Image {!section ? (
src={navigationIconPath("overview")!} <ViewTransition
alt="" name={`guide-nav-active-${guide.id}`}
width={20} share="guide-nav-pill"
height={20} default="none"
className="size-5 object-contain" >
/> <span aria-hidden="true" className="absolute inset-0 rounded-md bg-muted" />
Overview </ViewTransition>
) : null}
<span className="relative flex items-center gap-2">
<Image
src={navigationIconPath("overview")!}
alt=""
width={20}
height={20}
className="size-5 object-contain"
/>
Overview
</span>
</Link> </Link>
{sections.map((item) => ( {sections.map((item) => (
<Link <Link
key={item.id} key={item.id}
href={`/${guide.slug}/${item.slug}`} href={`/${guide.slug}/${item.slug}`}
className={cn( className={cn(
"flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring", "relative flex min-h-8 shrink-0 items-center whitespace-nowrap rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
section === item.slug section === item.slug
? "bg-muted text-foreground" ? "text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground", : "text-muted-foreground hover:bg-muted hover:text-foreground",
)} )}
prefetch prefetch
> >
{navigationIconPath(item.slug) ? ( {section === item.slug ? (
<Image <ViewTransition
src={navigationIconPath(item.slug)!} name={`guide-nav-active-${guide.id}`}
alt="" share="guide-nav-pill"
width={20} default="none"
height={20} >
className="size-5 object-contain" <span aria-hidden="true" className="absolute inset-0 rounded-md bg-muted" />
/> </ViewTransition>
) : null} ) : null}
{item.title} <span className="relative flex items-center gap-2">
{navigationIconPath(item.slug) ? (
<Image
src={navigationIconPath(item.slug)!}
alt=""
width={20}
height={20}
className="size-5 object-contain"
/>
) : null}
{item.title}
</span>
</Link> </Link>
))} ))}
</ScrollableNav> </ScrollableNav>
@@ -1003,7 +1026,13 @@ function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) {
</header> </header>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
{extra.rows.map((row) => ( {extra.rows.map((row) => (
<Card key={row.id} className="overflow-hidden py-0"> <Card
key={row.id}
className="overflow-hidden py-0"
style={row.backgroundColor && row.textColor
? { backgroundColor: row.backgroundColor, color: row.textColor }
: undefined}
>
{row.imageUrl ? ( {row.imageUrl ? (
<div <div
className="relative bg-muted" className="relative bg-muted"
@@ -1023,9 +1052,20 @@ function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) {
</div> </div>
) : null} ) : null}
<CardHeader className="pt-5"> <CardHeader className="pt-5">
<CardTitle>{row.heading}</CardTitle> <CardTitle style={row.textColor ? { color: row.textColor } : undefined}>
<CardDescription>{row.body}</CardDescription> {row.heading}
{row.percent ? <Badge>{metric(row.percent)}</Badge> : null} </CardTitle>
<CardDescription
className="whitespace-pre-wrap"
style={row.textColor ? { color: row.textColor } : undefined}
>
{row.body}
</CardDescription>
{row.percent ? (
<Badge style={row.textColor ? { color: row.textColor } : undefined}>
{metric(row.percent)}
</Badge>
) : null}
</CardHeader> </CardHeader>
</Card> </Card>
))} ))}
+2
View File
@@ -658,6 +658,8 @@ export const extraSectionRows = guidesSchema.table(
heading: text("heading").notNull(), heading: text("heading").notNull(),
body: text("body").notNull(), body: text("body").notNull(),
percent: numeric("percent", { precision: 8, scale: 2 }), percent: numeric("percent", { precision: 8, scale: 2 }),
backgroundColor: varchar("background_color", { length: 7 }),
textColor: varchar("text_color", { length: 7 }),
sortOrder: integer("sort_order").default(0).notNull(), sortOrder: integer("sort_order").default(0).notNull(),
}, },
(table) => [index("extra_section_row_order_idx").on(table.sectionId, table.sortOrder)], (table) => [index("extra_section_row_order_idx").on(table.sectionId, table.sortOrder)],
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "guides"."extra_section_row" ADD COLUMN "background_color" varchar(7);--> statement-breakpoint
ALTER TABLE "guides"."extra_section_row" ADD COLUMN "text_color" varchar(7);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -183,6 +183,13 @@
"when": 1788531440452, "when": 1788531440452,
"tag": "0025_bored_cobalt_man", "tag": "0025_bored_cobalt_man",
"breakpoints": true "breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1788533844823,
"tag": "0026_red_masque",
"breakpoints": true
} }
] ]
} }
+20 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { calculateTeamMetrics } from "@/lib/guides/calculations"; import { calculateTeamMetrics } from "@/lib/guides/calculations";
import { artifactSectionSchema, constellationSectionSchema, createGuideSchema, teamSectionSchema, updateGuideSchema, weaponSectionSchema } from "@/lib/guides/schemas"; import { artifactSectionSchema, constellationSectionSchema, createGuideSchema, extraSectionSchema, teamSectionSchema, updateGuideSchema, weaponSectionSchema } from "@/lib/guides/schemas";
import { import {
recommendedStatElement, recommendedStatElement,
recommendedStatIconName, recommendedStatIconName,
@@ -138,6 +138,25 @@ describe("structured guide validation", () => {
const input = { expectedVersion: 1, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }] }; const input = { expectedVersion: 1, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }] };
expect(teamSectionSchema.safeParse(input).success).toBe(true); expect(teamSectionSchema.safeParse(input).success).toBe(true);
}); });
it("accepts Extra card colors only as a valid pair", () => {
const row = {
mediaId: null,
heading: "Extra card",
body: "First line\nSecond line",
percent: null,
};
const input = (colors: { backgroundColor: string | null; textColor: string | null }) => ({
expectedVersion: 1,
sections: [{ title: "Extra", rows: [{ ...row, ...colors }] }],
});
expect(extraSectionSchema.safeParse(input({ backgroundColor: null, textColor: null })).success).toBe(true);
expect(extraSectionSchema.safeParse(input({ backgroundColor: "#24232C", textColor: "#F5F2EA" })).success).toBe(true);
expect(extraSectionSchema.safeParse({ expectedVersion: 1, sections: [{ title: "Extra", rows: [row] }] }).success).toBe(true);
expect(extraSectionSchema.safeParse(input({ backgroundColor: "#24232C", textColor: null })).success).toBe(false);
expect(extraSectionSchema.safeParse(input({ backgroundColor: "red", textColor: "#F5F2EA" })).success).toBe(false);
});
}); });
describe("Team Calc", () => { describe("Team Calc", () => {
+15 -6
View File
@@ -131,16 +131,25 @@ export const teamSectionSchema = z.strictObject({
})).max(30), })).max(30),
}); });
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/, "กรุณาเลือกสีที่ถูกต้อง");
const extraRowSchema = z.strictObject({
mediaId: z.string().uuid().nullable(),
heading: z.string().trim().min(1).max(200),
body: z.string().trim().max(2_000),
percent: percent.nullable(),
backgroundColor: hexColor.nullable().default(null),
textColor: hexColor.nullable().default(null),
}).refine(
(row) => (row.backgroundColor === null) === (row.textColor === null),
{ message: "ต้องเลือกสีพื้นหลังและสีข้อความพร้อมกัน", path: ["backgroundColor"] },
);
export const extraSectionSchema = z.strictObject({ export const extraSectionSchema = z.strictObject({
expectedVersion: z.number().int().positive(), expectedVersion: z.number().int().positive(),
sections: z.array(z.strictObject({ sections: z.array(z.strictObject({
title: z.string().trim().min(1).max(160), title: z.string().trim().min(1).max(160),
rows: z.array(z.strictObject({ rows: z.array(extraRowSchema).max(50),
mediaId: z.string().uuid().nullable(),
heading: z.string().trim().min(1).max(200),
body: z.string().trim().max(2_000),
percent: percent.nullable(),
})).max(50),
})).max(20), })).max(20),
}); });