77 lines
9.9 KiB
TypeScript
77 lines
9.9 KiB
TypeScript
"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 { Input } from "@/components/ui/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>;
|
||
}
|