340 lines
13 KiB
TypeScript
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 { 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";
|
|
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>
|
|
);
|
|
}
|