feat(admin-images) : add standalone export generators
CI / Verify (push) Successful in 1m29s
CI / Build immutable images and deploy (push) Failing after 1m42s

This commit is contained in:
2026-09-29 17:13:24 +07:00 Unverified
parent 76478fe56d
commit db554228bc
21 changed files with 1179 additions and 64 deletions
-56
View File
@@ -1,56 +0,0 @@
import { redirect } from "next/navigation";
import { connection } from "next/server";
import { AdminHeader } from "@/components/admin/admin-header";
import { OverviewImageGenerator } from "@/components/admin/overview-image-generator";
import { getAdminSession } from "@/lib/auth/server";
import { getActiveCatalog } from "@/lib/guides/queries";
import { elementOrder } from "@/lib/overview-image";
export default async function OverviewImagePage() {
await connection();
if (!(await getAdminSession())) redirect("/admin/login");
const catalog = await getActiveCatalog();
if (!catalog) redirect("/admin?sync=required");
const elementIconUrls = Object.fromEntries(
elementOrder.map((element) => [
element,
catalog.characters.find(
(character) =>
character.element === element && character.elementIconUrl,
)?.elementIconUrl ?? null,
]),
);
return (
<div className="min-h-svh">
<AdminHeader />
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6 lg:p-8">
<header>
<p className="text-sm font-medium text-primary">Image generator</p>
<h1 className="font-heading text-3xl font-semibold">
สร้างภาพ Overview
</h1>
<p className="mt-2 text-muted-foreground">
สร้างและดาวน์โหลดภาพ PNG ขนาด 3000×3000 โดยไม่เปลี่ยนภาพใน Guide
</p>
</header>
<OverviewImageGenerator
characters={catalog.characters.map((character) => ({
key: character.key,
name: character.name,
aliases: character.aliases,
element: character.element,
rarity: character.rarity,
weaponType: character.weaponType,
imageUrl: character.imageUrl,
nameCardImageUrl: character.nameCardImageUrl,
}))}
elementIconUrls={elementIconUrls}
/>
</main>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { redirect } from "next/navigation";
import { ConstellationImageGenerator } from "@/components/admin/constellation-image-generator";
import { getActiveCatalog } from "@/lib/guides/queries";
export default async function ConstellationImagePage() {
const catalog = await getActiveCatalog();
if (!catalog) redirect("/admin?sync=required");
return (
<div className="flex flex-col gap-8">
<header>
<p className="text-sm font-medium text-primary">Image generator</p>
<h1 className="font-heading text-3xl font-semibold">สร้างภาพ Constellation</h1>
<p className="mt-2 text-muted-foreground">
สร้างกราฟดาเมจ Constellation จาก Catalog โดยไม่บันทึกข้อมูลลง Guide
</p>
</header>
<ConstellationImageGenerator characters={catalog.characters} />
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { redirect } from "next/navigation";
import { connection } from "next/server";
import { AdminHeader } from "@/components/admin/admin-header";
import { ImageGeneratorNav } from "@/components/admin/image-generator-nav";
import { getAdminSession } from "@/lib/auth/server";
export default async function ImageGeneratorLayout({
children,
}: LayoutProps<"/admin/image">) {
await connection();
if (!(await getAdminSession())) redirect("/admin/login");
return (
<div className="min-h-svh">
<AdminHeader />
<main className="mx-auto flex w-full max-w-[96rem] flex-col gap-6 p-4 sm:p-6 lg:flex-row lg:p-8">
<ImageGeneratorNav />
<div className="min-w-0 flex-1">{children}</div>
</main>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { redirect } from "next/navigation";
import { OverviewImageGenerator } from "@/components/admin/overview-image-generator";
import { getActiveCatalog } from "@/lib/guides/queries";
import { elementOrder } from "@/lib/overview-image";
export default async function OverviewImagePage() {
const catalog = await getActiveCatalog();
if (!catalog) redirect("/admin?sync=required");
const elementIconUrls = Object.fromEntries(
elementOrder.map((element) => [
element,
catalog.characters.find(
(character) =>
character.element === element && character.elementIconUrl,
)?.elementIconUrl ?? null,
]),
);
return (
<div className="flex flex-col gap-8">
<header>
<p className="text-sm font-medium text-primary">Image generator</p>
<h1 className="font-heading text-3xl font-semibold">
สร้างภาพ Overview
</h1>
<p className="mt-2 text-muted-foreground">
สร้างและดาวน์โหลดภาพ PNG ขนาด 3000×3000 โดยไม่เปลี่ยนภาพใน Guide
</p>
</header>
<OverviewImageGenerator
characters={catalog.characters.map((character) => ({
key: character.key,
name: character.name,
aliases: character.aliases,
element: character.element,
rarity: character.rarity,
weaponType: character.weaponType,
imageUrl: character.imageUrl,
nameCardImageUrl: character.nameCardImageUrl,
}))}
elementIconUrls={elementIconUrls}
/>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function ImageGeneratorPage() {
redirect("/admin/image/overview");
}
+26
View File
@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { TeamImageGenerator } from "@/components/admin/team-image-generator";
import { getActiveCatalog } from "@/lib/guides/queries";
export default async function TeamImagePage() {
const catalog = await getActiveCatalog();
if (!catalog) redirect("/admin?sync=required");
return (
<div className="flex flex-col gap-8">
<header>
<p className="text-sm font-medium text-primary">Image generator</p>
<h1 className="font-heading text-3xl font-semibold">สร้างภาพ Team Calc</h1>
<p className="mt-2 text-muted-foreground">
สร้างภาพ Team แบบเดียวกับหน้า Guide โดยไม่บันทึกข้อมูลลง Guide
</p>
</header>
<TeamImageGenerator
characters={catalog.characters}
weapons={catalog.weapons}
artifacts={catalog.artifacts}
/>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { WeaponImageGenerator } from "@/components/admin/weapon-image-generator";
import { getActiveCatalog } from "@/lib/guides/queries";
export default async function WeaponImagePage() {
const catalog = await getActiveCatalog();
if (!catalog) redirect("/admin?sync=required");
return (
<div className="flex flex-col gap-8">
<header>
<p className="text-sm font-medium text-primary">Image generator</p>
<h1 className="font-heading text-3xl font-semibold">สร้างภาพ Weapon</h1>
<p className="mt-2 text-muted-foreground">
สร้างภาพเปรียบเทียบอาวุธจาก Catalog โดยไม่บันทึกข้อมูลลง Guide
</p>
</header>
<WeaponImageGenerator
characters={catalog.characters}
weapons={catalog.weapons}
/>
</div>
);
}
+2 -2
View File
@@ -79,11 +79,11 @@ export function AdminHeader() {
</Button>
<Button
variant={
pathname === "/admin/generate/image" ? "secondary" : "ghost"
pathname.startsWith("/admin/image") ? "secondary" : "ghost"
}
size="sm"
nativeButton={false}
render={<Link href="/admin/generate/image" />}
render={<Link href="/admin/image/overview" />}
>
<ImageIcon data-icon="inline-start" />
<span className="hidden sm:inline">Image Generator</span>
@@ -0,0 +1,225 @@
"use client";
import { PlusIcon, RotateCcwIcon } from "lucide-react";
import { useMemo } from "react";
import * as z from "zod";
import {
ImageCatalogPicker,
type ImageCatalogCharacter,
} from "@/components/admin/image-generator-fields";
import { useImageGeneratorDraft } from "@/components/admin/use-image-generator-draft";
import {
ConstellationImage,
type ConstellationImageGroup,
type ConstellationImageGuide,
} from "@/components/public/constellation-image";
import { GuideImageExport } from "@/components/public/guide-image-export";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
const rowSchema = z.object({
level: z.number().int().min(1).max(6),
cumulativePercent: z.string(),
isGreat: z.boolean(),
});
const draftSchema = z.object({
characterKey: z.string(),
note: z.string(),
rows: z.array(rowSchema).max(6).refine((rows) => rows.length === 0 || rows.length === 6),
});
type ConstellationDraft = z.infer<typeof draftSchema>;
const INITIAL_CONSTELLATION_DRAFT: ConstellationDraft = {
characterKey: "",
note: "",
rows: [],
};
const constellationRows = (): ConstellationDraft["rows"] =>
Array.from({ length: 6 }, (_, index) => ({
level: index + 1,
cumulativePercent: "100",
isGreat: false,
}));
const parseConstellationDraft = (value: unknown): ConstellationDraft | null => {
const parsed = draftSchema.safeParse(value);
return parsed.success ? parsed.data : null;
};
function validPercent(value: string): boolean {
const number = Number(value);
return value.trim().length > 0 && Number.isFinite(number) && number >= -9999 && number <= 9999;
}
export function ConstellationImageGenerator({
characters,
}: {
characters: ImageCatalogCharacter[];
}) {
const [draft, setDraft, resetDraft] = useImageGeneratorDraft(
"buzz-sheet:admin-image:constellation",
INITIAL_CONSTELLATION_DRAFT,
parseConstellationDraft,
);
const character = characters.find((item) => item.key === draft.characterKey);
const valid = Boolean(character && draft.rows.every((row) => validPercent(row.cumulativePercent)));
const guide = useMemo<ConstellationImageGuide>(() => ({
coverUrl: character?.imageUrl ?? "",
character: {
name: character?.name ?? "Character",
splashImageUrl: character?.splashImageUrl ?? character?.imageUrl ?? null,
},
}), [character]);
const group = useMemo<ConstellationImageGroup>(() => ({
note: draft.note.trim() || null,
rows: draft.rows,
}), [draft.note, draft.rows]);
return (
<div className="grid items-start gap-6 xl:grid-cols-[minmax(24rem,1fr)_minmax(28rem,1fr)]">
<Card>
<CardHeader>
<CardTitle>ข้อมูล Constellation</CardTitle>
<CardDescription>กรอกดาเมจสะสม C1–C6 เทียบกับ C0 ที่ 100%</CardDescription>
<CardAction>
<Button type="button" variant="ghost" size="sm" onClick={resetDraft}>
<RotateCcwIcon data-icon="inline-start" />
Reset
</Button>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-5">
{!draft.rows.length ? (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon"><PlusIcon /></EmptyMedia>
<EmptyTitle>ยังไม่มี Constellation</EmptyTitle>
<EmptyDescription>สร้างรายการ C1–C6 เพื่อเริ่มกรอกเปอร์เซ็นต์ดาเมจ</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button type="button" onClick={() => setDraft((current) => ({ ...current, rows: constellationRows() }))}>
<PlusIcon data-icon="inline-start" />
สร้าง Constellation
</Button>
</EmptyContent>
</Empty>
) : (
<>
<FieldGroup>
<Field data-invalid={!character}>
<FieldLabel>ตัวละคร</FieldLabel>
<ImageCatalogPicker
items={characters}
value={draft.characterKey}
invalid={!character}
placeholder="ค้นหาตัวละคร…"
onChange={(characterKey) => setDraft((current) => ({ ...current, characterKey }))}
/>
</Field>
<Field>
<FieldLabel htmlFor="constellation-image-note">โน้ตท้ายภาพ (ถ้ามี)</FieldLabel>
<Textarea
id="constellation-image-note"
value={draft.note}
onChange={(event) => setDraft((current) => ({ ...current, note: event.target.value }))}
/>
</Field>
</FieldGroup>
<FieldGroup>
{draft.rows.map((row, index) => (
<Field key={row.level} data-invalid={!validPercent(row.cumulativePercent)}>
<div className="grid grid-cols-[3rem_minmax(0,1fr)_auto] items-center gap-3">
<FieldLabel htmlFor={`constellation-image-c${row.level}`}>
C{row.level}
</FieldLabel>
<Input
id={`constellation-image-c${row.level}`}
type="number"
step="0.01"
value={row.cumulativePercent}
aria-invalid={!validPercent(row.cumulativePercent)}
onChange={(event) => setDraft((current) => ({
...current,
rows: current.rows.map((item, rowIndex) => rowIndex === index
? { ...item, cumulativePercent: event.target.value }
: item),
}))}
/>
<Field orientation="horizontal">
<Switch
id={`constellation-image-great-${row.level}`}
checked={row.isGreat}
onCheckedChange={(isGreat) => setDraft((current) => ({
...current,
rows: current.rows.map((item, rowIndex) => rowIndex === index
? { ...item, isGreat }
: item),
}))}
/>
<FieldLabel htmlFor={`constellation-image-great-${row.level}`}>เด่น</FieldLabel>
</Field>
</div>
</Field>
))}
</FieldGroup>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>ภาพส่งออก</CardTitle>
<CardDescription>ใช้กราฟเดียวกับ Constellation export ใน Guide</CardDescription>
</CardHeader>
<CardContent>
{!draft.rows.length ? (
<Empty className="border">
<EmptyHeader>
<EmptyTitle>ยังไม่มี Preview</EmptyTitle>
<EmptyDescription>สร้าง Constellation แล้วกรอกข้อมูลเพื่อดูภาพส่งออก</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<GuideImageExport
fileName={`${character?.name ?? "character"}-Constellations.png`}
width={1500}
height={1500}
captureClassName="p-0"
modeSelection="inline"
disabled={!valid}
>
<ConstellationImage guide={guide} group={group} />
</GuideImageExport>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,93 @@
"use client";
import Image from "next/image";
import {
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
FuzzyCombobox,
} from "@/components/ui/combobox";
import { rarityGradientClass } from "@/lib/catalog/rarity";
import { cn } from "@/lib/utils";
export interface ImageCatalogItem {
key: string;
name: string;
aliases: string[];
imageUrl: string;
rarity: number | null;
}
export interface ImageCatalogCharacter extends ImageCatalogItem {
element: string | null;
weaponType: string | null;
splashImageUrl: string | null;
}
export interface ImageCatalogWeapon extends ImageCatalogItem {
weaponType: string | null;
}
export type ImageCatalogArtifact = ImageCatalogItem;
export function ImageCatalogPicker<Item extends ImageCatalogItem>({
items,
value,
onChange,
placeholder,
invalid = false,
}: {
items: Item[];
value: string;
onChange: (value: string) => void;
placeholder: string;
invalid?: boolean;
}) {
const selected = items.find((item) => item.key === value) ?? null;
return (
<FuzzyCombobox
items={items}
value={selected}
onValueChange={(item) => onChange(item?.key ?? "")}
itemToStringLabel={(item) => item.name}
itemToStringValue={(item) => item.key}
searchNames={(item) => [item.name, ...item.aliases]}
autoHighlight
>
<ComboboxInput
className="w-full"
placeholder={placeholder}
showClear
aria-invalid={invalid}
/>
<ComboboxContent className="min-w-72">
<ComboboxEmpty>ไม่พบรายการ</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item.key} value={item}>
<span
className={cn(
"relative size-8 shrink-0 overflow-hidden rounded-md",
rarityGradientClass(item.rarity),
)}
>
<Image
src={item.imageUrl}
alt=""
fill
sizes="32px"
className="object-contain"
/>
</span>
<span>{item.name}</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</FuzzyCombobox>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
ChartNoAxesColumnIncreasingIcon,
ImageIcon,
SwordsIcon,
UsersIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
const links = [
{ href: "/admin/image/overview", label: "Overview", icon: ImageIcon },
{ href: "/admin/image/team", label: "Team", icon: UsersIcon },
{ href: "/admin/image/weapon", label: "Weapon", icon: SwordsIcon },
{
href: "/admin/image/constellation",
label: "Constellation",
icon: ChartNoAxesColumnIncreasingIcon,
},
];
export function ImageGeneratorNav() {
const pathname = usePathname();
return (
<aside className="min-w-0 lg:w-48 lg:shrink-0">
<nav
aria-label="Image generator navigation"
className="flex min-w-0 gap-1 overflow-x-auto lg:sticky lg:top-4 lg:flex-col"
>
{links.map(({ href, label, icon: Icon }) => (
<Button
key={href}
variant={pathname === href ? "secondary" : "ghost"}
className="justify-start"
nativeButton={false}
render={<Link href={href} />}
>
<Icon data-icon="inline-start" />
{label}
</Button>
))}
</nav>
</aside>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
import { useMemo } from "react";
import * as z from "zod";
import { ImageCatalogPicker, type ImageCatalogArtifact, type ImageCatalogCharacter, type ImageCatalogWeapon } from "@/components/admin/image-generator-fields";
import { useImageGeneratorDraft } from "@/components/admin/use-image-generator-draft";
import { GuideImageExport } from "@/components/public/guide-image-export";
import { TeamExportImage, type TeamExportData } from "@/components/public/team-search";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { supportsWeaponType } from "@/lib/catalog/lunaris";
import { teamConstellationLabel, teamRefinementLabel } from "@/lib/guides/team-tools";
const memberSchema = z.object({ characterKey: z.string(), weaponKey: z.string(), artifactKey: z.string(), secondArtifactKey: z.string(), constellation: z.enum(["0", "1", "2", "3", "4", "5", "6", "0-6"]), refinement: z.enum(["1", "2", "3", "4", "5", "1-5"]), damage: z.string() });
const draftSchema = z.object({ title: z.string(), rotationSeconds: z.string(), combo: z.string(), members: z.array(memberSchema).max(4) });
type TeamDraft = z.infer<typeof draftSchema>;
type TeamMemberDraft = TeamDraft["members"][number];
const emptyMember = (): TeamMemberDraft => ({ characterKey: "", weaponKey: "", artifactKey: "", secondArtifactKey: "", constellation: "0", refinement: "1", damage: "0" });
const INITIAL_TEAM_DRAFT: TeamDraft = { title: "", rotationSeconds: "20", combo: "", members: [] };
const parseTeamDraft = (value: unknown): TeamDraft | null => { const parsed = draftSchema.safeParse(value); return parsed.success ? parsed.data : null; };
const numericValue = (value: string) => Number.isFinite(Number(value)) ? Number(value) : 0;
export function TeamImageGenerator({ characters, weapons, artifacts }: { characters: ImageCatalogCharacter[]; weapons: ImageCatalogWeapon[]; artifacts: ImageCatalogArtifact[] }) {
const [draft, setDraft, resetDraft] = useImageGeneratorDraft("buzz-sheet:admin-image:team", INITIAL_TEAM_DRAFT, parseTeamDraft);
const selectedKeys = draft.members.map((member) => member.characterKey).filter(Boolean);
const valid = Boolean(draft.title.trim() && draft.combo.trim() && numericValue(draft.rotationSeconds) > 0 && draft.members.length && draft.members.every((member) => member.characterKey && member.weaponKey && member.artifactKey && member.secondArtifactKey !== member.artifactKey && numericValue(member.damage) >= 0) && new Set(selectedKeys).size === draft.members.length && draft.members.some((member) => numericValue(member.damage) > 0));
const exportTeam = useMemo<TeamExportData>(() => ({
title: draft.title.trim() || "Team", rotationSeconds: draft.rotationSeconds, combo: draft.combo,
members: draft.members.flatMap((member, position) => {
const character = characters.find((item) => item.key === member.characterKey);
if (!character) return [];
return [{ position, constellation: member.constellation, refinement: member.refinement, damage: member.damage, character, weapon: weapons.find((item) => item.key === member.weaponKey) ?? null, artifact: artifacts.find((item) => item.key === member.artifactKey) ?? null, secondArtifact: artifacts.find((item) => item.key === member.secondArtifactKey) ?? null }];
}),
}), [artifacts, characters, draft, weapons]);
const updateMember = (index: number, update: Partial<TeamMemberDraft>) => setDraft((current) => ({ ...current, members: current.members.map((member, memberIndex) => memberIndex === index ? { ...member, ...update } : member) }));
return <div className="grid items-start gap-6 xl:grid-cols-[minmax(24rem,1fr)_minmax(28rem,1fr)]">
<Card>
<CardHeader><CardTitle>ข้อมูล Team</CardTitle><CardDescription>รองรับตัวละคร 1–4 คน และคำนวณ DPS อัตโนมัติ</CardDescription><CardAction><Button type="button" variant="ghost" size="sm" onClick={resetDraft}><RotateCcwIcon data-icon="inline-start" />Reset</Button></CardAction></CardHeader>
<CardContent className="flex flex-col gap-5">
{!draft.members.length ? <Empty className="border"><EmptyHeader><EmptyMedia variant="icon"><PlusIcon /></EmptyMedia><EmptyTitle>ยังไม่มี Team</EmptyTitle><EmptyDescription>เพิ่มตัวละครคนแรกเพื่อเริ่มกรอกข้อมูล Team</EmptyDescription></EmptyHeader><EmptyContent><Button type="button" onClick={() => setDraft((current) => ({ ...current, members: [emptyMember()] }))}><PlusIcon data-icon="inline-start" />เพิ่ม Team</Button></EmptyContent></Empty> : <>
<FieldGroup>
<Field data-invalid={!draft.title.trim()}><FieldLabel htmlFor="team-image-title">ชื่อ Team</FieldLabel><Input id="team-image-title" value={draft.title} aria-invalid={!draft.title.trim()} onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))} /></Field>
<Field data-invalid={numericValue(draft.rotationSeconds) <= 0}><FieldLabel htmlFor="team-image-rotation">ระยะเวลา Rotation (วินาที)</FieldLabel><Input id="team-image-rotation" type="number" min="0.01" max="9999" step="0.01" value={draft.rotationSeconds} aria-invalid={numericValue(draft.rotationSeconds) <= 0} onChange={(event) => setDraft((current) => ({ ...current, rotationSeconds: event.target.value }))} /></Field>
<Field data-invalid={!draft.combo.trim()}><FieldLabel htmlFor="team-image-combo">Combo</FieldLabel><Textarea id="team-image-combo" value={draft.combo} aria-invalid={!draft.combo.trim()} onChange={(event) => setDraft((current) => ({ ...current, combo: event.target.value }))} /></Field>
</FieldGroup>
{draft.members.map((member, index) => {
const character = characters.find((item) => item.key === member.characterKey);
const compatibleWeapons = weapons.filter((weapon) => supportsWeaponType(character?.weaponType, weapon.weaponType));
return <Card key={index} size="sm"><CardHeader><CardTitle>ตัวละคร #{index + 1}</CardTitle><CardAction><Button type="button" variant="ghost" size="icon-sm" aria-label={`ลบตัวละคร ${index + 1}`} onClick={() => setDraft((current) => ({ ...current, members: current.members.filter((_, memberIndex) => memberIndex !== index) }))}><Trash2Icon /></Button></CardAction></CardHeader><CardContent><FieldGroup>
<Field><FieldLabel>ตัวละคร</FieldLabel><ImageCatalogPicker items={characters} value={member.characterKey} placeholder="ค้นหาตัวละคร…" onChange={(characterKey) => updateMember(index, { ...emptyMember(), characterKey })} /></Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field><FieldLabel>อาวุธ</FieldLabel><ImageCatalogPicker items={compatibleWeapons} value={member.weaponKey} placeholder="ค้นหาอาวุธที่รองรับ…" onChange={(weaponKey) => updateMember(index, { weaponKey, refinement: "1" })} /></Field>
<Field><FieldLabel>Refinement</FieldLabel><Select value={member.refinement} onValueChange={(value) => { if (value) updateMember(index, { refinement: value as TeamMemberDraft["refinement"] }); }}><SelectTrigger><SelectValue>{teamRefinementLabel(member.refinement)}</SelectValue></SelectTrigger><SelectContent><SelectGroup>{["1", "2", "3", "4", "5", "1-5"].map((value) => <SelectItem key={value} value={value}>{teamRefinementLabel(value)}</SelectItem>)}</SelectGroup></SelectContent></Select></Field>
<Field><FieldLabel>Artifact</FieldLabel><ImageCatalogPicker items={artifacts} value={member.artifactKey} placeholder="ค้นหา Artifact…" onChange={(artifactKey) => updateMember(index, { artifactKey })} /></Field>
<Field><FieldLabel>Artifact ชุดที่ 2 (ถ้ามี)</FieldLabel><ImageCatalogPicker items={artifacts.filter((item) => item.key !== member.artifactKey)} value={member.secondArtifactKey} placeholder="ค้นหา Artifact…" onChange={(secondArtifactKey) => updateMember(index, { secondArtifactKey })} /></Field>
<Field><FieldLabel>Constellation</FieldLabel><Select value={member.constellation} onValueChange={(value) => { if (value) updateMember(index, { constellation: value as TeamMemberDraft["constellation"] }); }}><SelectTrigger><SelectValue>{teamConstellationLabel(member.constellation)}</SelectValue></SelectTrigger><SelectContent><SelectGroup>{["0", "1", "2", "3", "4", "5", "6", "0-6"].map((value) => <SelectItem key={value} value={value}>{teamConstellationLabel(value)}</SelectItem>)}</SelectGroup></SelectContent></Select></Field>
<Field><FieldLabel htmlFor={`team-image-damage-${index}`}>Damage</FieldLabel><Input id={`team-image-damage-${index}`} type="number" min="0" value={member.damage} onChange={(event) => updateMember(index, { damage: event.target.value })} /></Field>
</div>
</FieldGroup></CardContent></Card>;
})}
<Button type="button" variant="outline" disabled={draft.members.length >= 4} onClick={() => setDraft((current) => ({ ...current, members: [...current.members, emptyMember()] }))}><PlusIcon data-icon="inline-start" />เพิ่มตัวละคร</Button>
</>}
</CardContent>
</Card>
<Card><CardHeader><CardTitle>ภาพส่งออก</CardTitle><CardDescription>DPS จะคำนวณจาก Damage รวม ÷ Rotation</CardDescription></CardHeader><CardContent>
{!draft.members.length ? <Empty className="border"><EmptyHeader><EmptyTitle>ยังไม่มี Preview</EmptyTitle><EmptyDescription>เพิ่ม Team แล้วกรอกข้อมูลเพื่อดูภาพส่งออก</EmptyDescription></EmptyHeader></Empty> : <GuideImageExport fileName={`${draft.title.trim() || "team"}.png`} width={1080} captureClassName="p-0" allowWithoutNotes={false} modeSelection="inline" disabled={!valid}><TeamExportImage team={exportTeam} /></GuideImageExport>}
</CardContent></Card>
</div>;
}
@@ -0,0 +1,56 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
parseImageGeneratorDraft,
serializeImageGeneratorDraft,
} from "@/lib/image-generator-draft";
export function useImageGeneratorDraft<Value>(
key: string,
initialValue: Value,
parse: (value: unknown) => Value | null,
) {
const [value, setValue] = useState(initialValue);
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
const timer = window.setTimeout(() => {
try {
const serialized = window.localStorage.getItem(key);
if (serialized) {
const parsed = parseImageGeneratorDraft(serialized, parse);
if (parsed) setValue(parsed);
}
} catch {
// A corrupt or unavailable browser draft should not block the generator.
}
setHydrated(true);
}, 0);
return () => window.clearTimeout(timer);
}, [key, parse]);
useEffect(() => {
if (!hydrated) return;
try {
window.localStorage.setItem(
key,
serializeImageGeneratorDraft(value),
);
} catch {
// Browser storage is optional; the current generator remains usable.
}
}, [hydrated, key, value]);
const reset = useCallback(() => {
try {
window.localStorage.removeItem(key);
} catch {
// Browser storage is optional.
}
setValue(initialValue);
}, [initialValue, key]);
return [value, setValue, reset] as const;
}
+343
View File
@@ -0,0 +1,343 @@
"use client";
import { PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
import { useMemo } from "react";
import * as z from "zod";
import {
ImageCatalogPicker,
type ImageCatalogCharacter,
type ImageCatalogWeapon,
} from "@/components/admin/image-generator-fields";
import { useImageGeneratorDraft } from "@/components/admin/use-image-generator-draft";
import { GuideImageExport } from "@/components/public/guide-image-export";
import {
WeaponComparisonImage,
type DamageMetric,
type WeaponExportGuide,
type WeaponGroup,
} from "@/components/public/weapon-image-export";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { supportsWeaponType } from "@/lib/catalog/lunaris";
const rowSchema = z.object({
id: z.string(),
weaponKey: z.string(),
refinement: z.enum(["1", "2", "3", "4", "5", "1-5"]),
overallPercent: z.string(),
personalPercent: z.string(),
note: z.string(),
});
const draftSchema = z.object({
characterKey: z.string(),
conditionLabel: z.string(),
note: z.string(),
metric: z.enum(["overallPercent", "personalPercent"]),
rows: z.array(rowSchema).max(100),
});
type WeaponDraft = z.infer<typeof draftSchema>;
type WeaponRowDraft = WeaponDraft["rows"][number];
let nextRowId = 1;
const emptyRow = (): WeaponRowDraft => ({
id: `weapon-image-${nextRowId++}`,
weaponKey: "",
refinement: "1",
overallPercent: "100",
personalPercent: "",
note: "",
});
const INITIAL_WEAPON_DRAFT: WeaponDraft = {
characterKey: "",
conditionLabel: "",
note: "",
metric: "overallPercent",
rows: [],
};
const parseWeaponDraft = (value: unknown): WeaponDraft | null => {
const parsed = draftSchema.safeParse(value);
return parsed.success ? parsed.data : null;
};
function validPercent(value: string): boolean {
if (!value.trim()) return false;
const number = Number(value);
return Number.isFinite(number) && number >= -9999 && number <= 9999;
}
export function WeaponImageGenerator({
characters,
weapons,
}: {
characters: ImageCatalogCharacter[];
weapons: ImageCatalogWeapon[];
}) {
const [draft, setDraft, resetDraft] = useImageGeneratorDraft(
"buzz-sheet:admin-image:weapon",
INITIAL_WEAPON_DRAFT,
parseWeaponDraft,
);
const character = characters.find((item) => item.key === draft.characterKey);
const compatibleWeapons = weapons.filter((weapon) =>
supportsWeaponType(character?.weaponType, weapon.weaponType),
);
const valid = Boolean(
character &&
draft.rows.every((row) =>
compatibleWeapons.some((weapon) => weapon.key === row.weaponKey) &&
(validPercent(row.overallPercent) || validPercent(row.personalPercent)),
) &&
draft.rows.some((row) => validPercent(row[draft.metric])),
);
const guide = useMemo<WeaponExportGuide>(() => ({
name: character?.name ?? "Character",
coverUrl: character?.imageUrl ?? "",
character: {
name: character?.name ?? "Character",
splashImageUrl: character?.splashImageUrl ?? character?.imageUrl ?? null,
},
}), [character]);
const group = useMemo<WeaponGroup>(() => ({
name: draft.conditionLabel.trim() || null,
note: draft.note.trim() || null,
rows: draft.rows.flatMap((row) => {
const weapon = compatibleWeapons.find((item) => item.key === row.weaponKey);
if (!weapon) return [];
return [{
id: row.id,
refinement: row.refinement,
overallPercent: validPercent(row.overallPercent) ? row.overallPercent : null,
personalPercent: validPercent(row.personalPercent) ? row.personalPercent : null,
note: row.note.trim() || null,
weapon,
}];
}),
}), [compatibleWeapons, draft]);
const updateRow = (id: string, update: Partial<WeaponRowDraft>) => {
setDraft((current) => ({
...current,
rows: current.rows.map((row) => row.id === id ? { ...row, ...update } : row),
}));
};
return (
<div className="grid items-start gap-6 xl:grid-cols-[minmax(24rem,1fr)_minmax(28rem,1fr)]">
<Card>
<CardHeader>
<CardTitle>ข้อมูลเปรียบเทียบอาวุธ</CardTitle>
<CardDescription>เลือกตัวละครและกรอกเปอร์เซ็นต์จากการคำนวณ</CardDescription>
<CardAction>
<Button type="button" variant="ghost" size="sm" onClick={resetDraft}>
<RotateCcwIcon data-icon="inline-start" />
Reset
</Button>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-5">
{!draft.rows.length ? (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon"><PlusIcon /></EmptyMedia>
<EmptyTitle>ยังไม่มีรายการอาวุธ</EmptyTitle>
<EmptyDescription>เพิ่มอาวุธรายการแรกเพื่อเริ่มสร้างภาพเปรียบเทียบ</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button type="button" onClick={() => setDraft((current) => ({ ...current, rows: [emptyRow()] }))}>
<PlusIcon data-icon="inline-start" />
เพิ่มรายการอาวุธ
</Button>
</EmptyContent>
</Empty>
) : (
<>
<FieldGroup>
<Field data-invalid={!character}>
<FieldLabel>ตัวละคร</FieldLabel>
<ImageCatalogPicker
items={characters}
value={draft.characterKey}
invalid={!character}
placeholder="ค้นหาตัวละคร…"
onChange={(characterKey) => setDraft((current) => ({
...current,
characterKey,
rows: current.rows.map((row) => ({ ...row, weaponKey: "" })),
}))}
/>
</Field>
<Field>
<FieldLabel htmlFor="weapon-image-condition">ชื่อเงื่อนไข (ถ้ามี)</FieldLabel>
<Input
id="weapon-image-condition"
value={draft.conditionLabel}
onChange={(event) => setDraft((current) => ({ ...current, conditionLabel: event.target.value }))}
/>
</Field>
<Field>
<FieldLabel htmlFor="weapon-image-note">โน้ตท้ายภาพ (ถ้ามี)</FieldLabel>
<Textarea
id="weapon-image-note"
value={draft.note}
onChange={(event) => setDraft((current) => ({ ...current, note: event.target.value }))}
/>
</Field>
</FieldGroup>
{draft.rows.map((row, index) => (
<Card key={row.id} size="sm">
<CardHeader>
<CardTitle>อาวุธ #{index + 1}</CardTitle>
{draft.rows.length ? (
<CardAction>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`ลบอาวุธ ${index + 1}`}
onClick={() => setDraft((current) => ({ ...current, rows: current.rows.filter((item) => item.id !== row.id) }))}
>
<Trash2Icon />
</Button>
</CardAction>
) : null}
</CardHeader>
<CardContent>
<FieldGroup>
<Field data-invalid={!row.weaponKey}>
<FieldLabel>อาวุธ</FieldLabel>
<ImageCatalogPicker
items={compatibleWeapons}
value={row.weaponKey}
invalid={!row.weaponKey}
placeholder="ค้นหาอาวุธ…"
onChange={(weaponKey) => updateRow(row.id, { weaponKey })}
/>
</Field>
<div className="grid gap-4 sm:grid-cols-3">
<Field>
<FieldLabel>Refinement</FieldLabel>
<Select value={row.refinement} onValueChange={(value) => {
if (value) updateRow(row.id, { refinement: value as WeaponRowDraft["refinement"] });
}}>
<SelectTrigger><SelectValue>R{row.refinement}</SelectValue></SelectTrigger>
<SelectContent><SelectGroup>
{["1", "2", "3", "4", "5", "1-5"].map((value) => <SelectItem key={value} value={value}>R{value}</SelectItem>)}
</SelectGroup></SelectContent>
</Select>
</Field>
<Field data-invalid={Boolean(row.overallPercent) && !validPercent(row.overallPercent)}>
<FieldLabel htmlFor={`${row.id}-overall`}>Overall %</FieldLabel>
<Input id={`${row.id}-overall`} type="number" value={row.overallPercent} aria-invalid={Boolean(row.overallPercent) && !validPercent(row.overallPercent)} onChange={(event) => updateRow(row.id, { overallPercent: event.target.value })} />
</Field>
<Field data-invalid={Boolean(row.personalPercent) && !validPercent(row.personalPercent)}>
<FieldLabel htmlFor={`${row.id}-personal`}>Personal %</FieldLabel>
<Input id={`${row.id}-personal`} type="number" value={row.personalPercent} aria-invalid={Boolean(row.personalPercent) && !validPercent(row.personalPercent)} onChange={(event) => updateRow(row.id, { personalPercent: event.target.value })} />
</Field>
</div>
<Field>
<FieldLabel htmlFor={`${row.id}-note`}>โน้ต (ถ้ามี)</FieldLabel>
<Input id={`${row.id}-note`} value={row.note} onChange={(event) => updateRow(row.id, { note: event.target.value })} />
</Field>
</FieldGroup>
</CardContent>
</Card>
))}
<Button type="button" variant="outline" disabled={draft.rows.length >= 100} onClick={() => setDraft((current) => ({ ...current, rows: [...current.rows, emptyRow()] }))}>
<PlusIcon data-icon="inline-start" />
เพิ่มอาวุธ
</Button>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>ภาพส่งออก</CardTitle>
<CardDescription>ใช้หน้าตาเดียวกับภาพเปรียบเทียบอาวุธใน Guide</CardDescription>
</CardHeader>
<CardContent>
{!draft.rows.length ? (
<Empty className="border">
<EmptyHeader>
<EmptyTitle>ยังไม่มี Preview</EmptyTitle>
<EmptyDescription>เพิ่มรายการอาวุธแล้วกรอกข้อมูลเพื่อดูภาพส่งออก</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<GuideImageExport
fileName={`${character?.name ?? "character"}-Weapon-${draft.metric === "overallPercent" ? "Overall Damage" : "Personal Damage"}.png`}
width={1200}
captureClassName="p-0"
modeSelection="inline"
disabled={!valid}
previewPosition="top"
additionalOptions={(
<Field>
<FieldLabel>ประเภทดาเมจ</FieldLabel>
<ToggleGroup
aria-label="ประเภทดาเมจในภาพส่งออก"
variant="outline"
value={[draft.metric]}
onValueChange={(values) => {
const metric = values[0];
if (metric === "overallPercent" || metric === "personalPercent") {
setDraft((current) => ({ ...current, metric: metric as DamageMetric }));
}
}}
>
<ToggleGroupItem value="overallPercent">ดาเมจทั้งทีม</ToggleGroupItem>
<ToggleGroupItem value="personalPercent">ดาเมจส่วนตัว</ToggleGroupItem>
</ToggleGroup>
</Field>
)}
>
<WeaponComparisonImage
guide={guide}
group={group}
metric={draft.metric}
conditionLabel={group.name}
/>
</GuideImageExport>
)}
</CardContent>
</Card>
</div>
);
}
+15 -2
View File
@@ -6,6 +6,19 @@ import type { PublicGuide } from "@/lib/guides/queries";
import { constellationChartRows } from "@/lib/guides/export-image";
import { cn } from "@/lib/utils";
export type ConstellationImageGuide = Pick<PublicGuide, "coverUrl"> & {
character: Pick<PublicGuide["character"], "name" | "splashImageUrl">;
};
export interface ConstellationImageGroup {
note: string | null;
rows: Array<{
level: number;
cumulativePercent: string;
isGreat: boolean;
}>;
}
function percentage(value: number): string {
return `${value.toLocaleString("en-US", { maximumFractionDigits: 2 })}%`;
}
@@ -14,8 +27,8 @@ export function ConstellationImage({
guide,
group,
}: {
guide: PublicGuide;
group: PublicGuide["constellationGroups"][number];
guide: ConstellationImageGuide;
group: ConstellationImageGroup;
}) {
const rows = constellationChartRows(group.rows);
const background = guide.character.splashImageUrl ?? guide.coverUrl;
+28 -1
View File
@@ -37,6 +37,33 @@ import {
} from "@/lib/guides/team-search";
import { cn } from "@/lib/utils";
export interface TeamExportData {
title: string;
rotationSeconds: string | number;
combo: string;
members: Array<{
position: number;
constellation: string;
refinement: string;
damage: string | number;
character: {
key: string;
name: string;
imageUrl: string;
element: string | null;
rarity: number | null;
};
weapon: {
key: string;
name: string;
imageUrl: string;
rarity: number | null;
} | null;
artifact: TeamArtifact | null;
secondArtifact: TeamArtifact | null;
}>;
}
type Team = PublicGuide["teams"][number];
const teamSearchTargetLabels: Record<TeamSearchTarget, string> = {
all: "ทั้งหมด",
@@ -179,7 +206,7 @@ export function TeamArtifactIcon({
);
}
function TeamExportImage({ team }: { team: Team }) {
export function TeamExportImage({ team }: { team: TeamExportData }) {
const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0);
const seconds = Number(team.rotationSeconds);
return (
+17 -3
View File
@@ -21,12 +21,26 @@ import {
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import type { PublicGuide } from "@/lib/guides/queries";
type WeaponExportGuide = Pick<PublicGuide, "name" | "coverUrl"> & {
export type WeaponExportGuide = Pick<PublicGuide, "name" | "coverUrl"> & {
character: Pick<PublicGuide["character"], "name" | "splashImageUrl">;
};
type WeaponGroup = PublicGuide["weaponGroups"][number];
type DamageMetric = "overallPercent" | "personalPercent";
export interface WeaponGroup {
name: string | null;
note: string | null;
rows: Array<{
id: string;
refinement: string;
overallPercent: string | null;
personalPercent: string | null;
note: string | null;
weapon: {
name: string;
imageUrl: string;
};
}>;
}
export type DamageMetric = "overallPercent" | "personalPercent";
function formattedPercent(value: string | null): string {
if (value === null) return "-%";
+44
View File
@@ -10,9 +10,53 @@ import {
import { MaterialPoster } from "@/components/public/material-poster";
import { ConstellationImage } from "@/components/public/constellation-image";
import { WeaponComparisonImage } from "@/components/public/weapon-image-export";
import { TeamExportImage, type TeamExportData } from "@/components/public/team-search";
import type { PublicGuide } from "@/lib/guides/queries";
describe("image export controls", () => {
it.each([1, 4])("renders the shared Team Calc image with %i member(s)", (memberCount) => {
const members: TeamExportData["members"] = Array.from({ length: memberCount }, (_, position) => ({
position,
constellation: String(position),
refinement: "1",
damage: "1000",
character: {
key: `character-${position}`,
name: `Character ${position + 1}`,
imageUrl: "/character.png",
element: "Fire",
rarity: 5,
},
weapon: {
key: `weapon-${position}`,
name: `Weapon ${position + 1}`,
imageUrl: "/weapon.png",
rarity: 5,
},
artifact: {
key: `artifact-${position}`,
name: `Artifact ${position + 1}`,
imageUrl: "/artifact.png",
rarity: 5,
},
secondArtifact: null,
}));
const team: TeamExportData = {
title: "Generator Team",
rotationSeconds: "20",
combo: "E > Q",
members,
};
const html = renderToStaticMarkup(<TeamExportImage team={team} />);
expect(html).toContain("Generator Team");
expect(html).toContain("Rotation duration: 20 seconds");
expect(html).toContain("E &gt; Q");
expect(html).toContain(`DPS: ${memberCount * 50}`);
expect(html.match(/Character \d/gu)).toHaveLength(memberCount);
});
it("omits the placeholder condition from weapon export images", () => {
const guide = {
character: { name: "Raiden", splashImageUrl: "/splash.png" },
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
IMAGE_GENERATOR_DRAFT_VERSION,
parseImageGeneratorDraft,
serializeImageGeneratorDraft,
} from "./image-generator-draft";
describe("image generator drafts", () => {
const parseTitle = (value: unknown) => {
if (!value || typeof value !== "object") return null;
const title = (value as { title?: unknown }).title;
return typeof title === "string" ? { title } : null;
};
it("round trips a versioned draft", () => {
const serialized = serializeImageGeneratorDraft({ title: "Hyperbloom" });
expect(parseImageGeneratorDraft(serialized, parseTitle)).toEqual({ title: "Hyperbloom" });
});
it("rejects corrupt, incompatible, and invalid drafts", () => {
expect(parseImageGeneratorDraft("not-json", parseTitle)).toBeNull();
expect(parseImageGeneratorDraft(JSON.stringify({ version: IMAGE_GENERATOR_DRAFT_VERSION + 1, value: { title: "Old" } }), parseTitle)).toBeNull();
expect(parseImageGeneratorDraft(JSON.stringify({ version: IMAGE_GENERATOR_DRAFT_VERSION, value: { title: 42 } }), parseTitle)).toBeNull();
});
});
+21
View File
@@ -0,0 +1,21 @@
export const IMAGE_GENERATOR_DRAFT_VERSION = 1;
export function serializeImageGeneratorDraft<Value>(value: Value): string {
return JSON.stringify({ version: IMAGE_GENERATOR_DRAFT_VERSION, value });
}
export function parseImageGeneratorDraft<Value>(
serialized: string,
parse: (value: unknown) => Value | null,
): Value | null {
try {
const envelope = JSON.parse(serialized) as {
version?: unknown;
value?: unknown;
};
if (envelope.version !== IMAGE_GENERATOR_DRAFT_VERSION) return null;
return parse(envelope.value);
} catch {
return null;
}
}
+35
View File
@@ -0,0 +1,35 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { ConstellationImageGenerator } from "@/components/admin/constellation-image-generator";
import { TeamImageGenerator } from "@/components/admin/team-image-generator";
import { WeaponImageGenerator } from "@/components/admin/weapon-image-generator";
describe("admin image generator empty states", () => {
it("starts Team with a create action instead of an empty form", () => {
const html = renderToStaticMarkup(
<TeamImageGenerator characters={[]} weapons={[]} artifacts={[]} />,
);
expect(html).toContain("ยังไม่มี Team");
expect(html).toContain("เพิ่ม Team");
expect(html).not.toContain("team-image-title");
});
it("starts Weapon with a create action instead of an empty row", () => {
const html = renderToStaticMarkup(
<WeaponImageGenerator characters={[]} weapons={[]} />,
);
expect(html).toContain("ยังไม่มีรายการอาวุธ");
expect(html).toContain("เพิ่มรายการอาวุธ");
expect(html).not.toContain("weapon-image-condition");
});
it("starts Constellation with one action that creates C1–C6", () => {
const html = renderToStaticMarkup(
<ConstellationImageGenerator characters={[]} />,
);
expect(html).toContain("ยังไม่มี Constellation");
expect(html).toContain("สร้าง Constellation");
expect(html).not.toContain("constellation-image-c1");
});
});