Files
buzz-sheet/components/admin/weapon-image-generator.tsx
T
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

340 lines
13 KiB
TypeScript

"use client";
import { PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
import { useMemo } from "react";
import * as z from "zod";
import {
CharacterSelect,
WeaponSelect,
type AdminCatalogCharacter,
type AdminCatalogWeapon,
} from "@/components/admin/ui/catalog-select";
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 { ClearableInput as Input } from "@/components/admin/ui/clearable-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";
import { isValidPercent } from "@/lib/guides/percent-input";
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;
};
export function WeaponImageGenerator({
characters,
weapons,
}: {
characters: AdminCatalogCharacter[];
weapons: AdminCatalogWeapon[];
}) {
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) &&
(isValidPercent(row.overallPercent) || isValidPercent(row.personalPercent)),
) &&
draft.rows.some((row) => isValidPercent(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: isValidPercent(row.overallPercent) ? row.overallPercent : null,
personalPercent: isValidPercent(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>
<CharacterSelect
items={characters}
value={draft.characterKey}
invalid={!character}
placeholder="ค้นหาตัวละคร…"
onValueChange={(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>
<WeaponSelect
items={compatibleWeapons}
value={row.weaponKey}
invalid={!row.weaponKey}
placeholder="ค้นหาอาวุธ…"
onValueChange={(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) && !isValidPercent(row.overallPercent)}>
<FieldLabel htmlFor={`${row.id}-overall`}>Overall %</FieldLabel>
<Input id={`${row.id}-overall`} type="number" value={row.overallPercent} aria-invalid={Boolean(row.overallPercent) && !isValidPercent(row.overallPercent)} onChange={(event) => updateRow(row.id, { overallPercent: event.target.value })} />
</Field>
<Field data-invalid={Boolean(row.personalPercent) && !isValidPercent(row.personalPercent)}>
<FieldLabel htmlFor={`${row.id}-personal`}>Personal %</FieldLabel>
<Input id={`${row.id}-personal`} type="number" value={row.personalPercent} aria-invalid={Boolean(row.personalPercent) && !isValidPercent(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>
);
}