Files
buzz-sheet/components/admin/team-image-generator.tsx
T
gunshiz db554228bc
CI / Verify (push) Successful in 1m29s
CI / Build immutable images and deploy (push) Failing after 1m42s
feat(admin-images) : add standalone export generators
2026-09-29 17:13:24 +07:00

78 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>;
}