Files
buzz-sheet/components/admin/team-image-generator.tsx
gunshiz 6d0feb11ca
CI / Verify (push) Successful in 1m45s
CI / Build immutable images and deploy (push) Successful in 2m26s
feat : update
2026-10-07 19:01:45 +07:00

77 lines
10 KiB
TypeScript
Raw Permalink 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 { ArtifactSelect, CharacterSelect, WeaponSelect, type AdminCatalogArtifact, type AdminCatalogCharacter, type AdminCatalogWeapon } from "@/components/admin/ui/catalog-select";
import { ConstellationSelect, RefinementSelect } from "@/components/admin/ui/option-select";
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 { ClearableInput as Input } from "@/components/admin/ui/clearable-input";
import { Textarea } from "@/components/ui/textarea";
import { supportsWeaponType } from "@/lib/catalog/lunaris";
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: AdminCatalogCharacter[]; weapons: AdminCatalogWeapon[]; artifacts: AdminCatalogArtifact[] }) {
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><CharacterSelect items={characters} value={member.characterKey} placeholder="ค้นหาตัวละคร…" onValueChange={(characterKey) => updateMember(index, { ...emptyMember(), characterKey })} /></Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field><FieldLabel>อาวุธ</FieldLabel><WeaponSelect items={compatibleWeapons} value={member.weaponKey} placeholder="ค้นหาอาวุธที่รองรับ…" onValueChange={(weaponKey) => updateMember(index, { weaponKey, refinement: "1" })} /></Field>
<Field><FieldLabel>Refinement</FieldLabel><RefinementSelect includeRange value={member.refinement} onValueChange={(value) => updateMember(index, { refinement: value as TeamMemberDraft["refinement"] })} triggerProps={{ className: "w-full" }} /></Field>
<Field><FieldLabel>Artifact</FieldLabel><ArtifactSelect items={artifacts} value={member.artifactKey} placeholder="ค้นหา Artifact…" onValueChange={(artifactKey) => updateMember(index, { artifactKey })} /></Field>
<Field><FieldLabel>Artifact ชุดที่ 2 (ถ้ามี)</FieldLabel><ArtifactSelect items={artifacts.filter((item) => item.key !== member.artifactKey)} value={member.secondArtifactKey} placeholder="ค้นหา Artifact…" onValueChange={(secondArtifactKey) => updateMember(index, { secondArtifactKey })} /></Field>
<Field><FieldLabel>Constellation</FieldLabel><ConstellationSelect includeRange value={member.constellation} onValueChange={(value) => updateMember(index, { constellation: value as TeamMemberDraft["constellation"] })} triggerProps={{ className: "w-full" }} /></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>;
}