feat(admin-images) : add standalone export generators
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import * as z from "zod";
|
||||
|
||||
import {
|
||||
ImageCatalogPicker,
|
||||
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 {
|
||||
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";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
function validPercent(value: string): boolean {
|
||||
if (!value.trim()) return false;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= -9999 && number <= 9999;
|
||||
}
|
||||
|
||||
export function WeaponImageGenerator({
|
||||
characters,
|
||||
weapons,
|
||||
}: {
|
||||
characters: ImageCatalogCharacter[];
|
||||
weapons: ImageCatalogWeapon[];
|
||||
}) {
|
||||
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) &&
|
||||
(validPercent(row.overallPercent) || validPercent(row.personalPercent)),
|
||||
) &&
|
||||
draft.rows.some((row) => validPercent(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: validPercent(row.overallPercent) ? row.overallPercent : null,
|
||||
personalPercent: validPercent(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>
|
||||
<ImageCatalogPicker
|
||||
items={characters}
|
||||
value={draft.characterKey}
|
||||
invalid={!character}
|
||||
placeholder="ค้นหาตัวละคร…"
|
||||
onChange={(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>
|
||||
<ImageCatalogPicker
|
||||
items={compatibleWeapons}
|
||||
value={row.weaponKey}
|
||||
invalid={!row.weaponKey}
|
||||
placeholder="ค้นหาอาวุธ…"
|
||||
onChange={(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) && !validPercent(row.overallPercent)}>
|
||||
<FieldLabel htmlFor={`${row.id}-overall`}>Overall %</FieldLabel>
|
||||
<Input id={`${row.id}-overall`} type="number" value={row.overallPercent} aria-invalid={Boolean(row.overallPercent) && !validPercent(row.overallPercent)} onChange={(event) => updateRow(row.id, { overallPercent: event.target.value })} />
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(row.personalPercent) && !validPercent(row.personalPercent)}>
|
||||
<FieldLabel htmlFor={`${row.id}-personal`}>Personal %</FieldLabel>
|
||||
<Input id={`${row.id}-personal`} type="number" value={row.personalPercent} aria-invalid={Boolean(row.personalPercent) && !validPercent(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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user