feat(admin-images) : add standalone export generators
This commit is contained in:
@@ -1,56 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
|
||||
import { AdminHeader } from "@/components/admin/admin-header";
|
||||
import { OverviewImageGenerator } from "@/components/admin/overview-image-generator";
|
||||
import { getAdminSession } from "@/lib/auth/server";
|
||||
import { getActiveCatalog } from "@/lib/guides/queries";
|
||||
import { elementOrder } from "@/lib/overview-image";
|
||||
|
||||
export default async function OverviewImagePage() {
|
||||
await connection();
|
||||
if (!(await getAdminSession())) redirect("/admin/login");
|
||||
|
||||
const catalog = await getActiveCatalog();
|
||||
if (!catalog) redirect("/admin?sync=required");
|
||||
|
||||
const elementIconUrls = Object.fromEntries(
|
||||
elementOrder.map((element) => [
|
||||
element,
|
||||
catalog.characters.find(
|
||||
(character) =>
|
||||
character.element === element && character.elementIconUrl,
|
||||
)?.elementIconUrl ?? null,
|
||||
]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<AdminHeader />
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6 lg:p-8">
|
||||
<header>
|
||||
<p className="text-sm font-medium text-primary">Image generator</p>
|
||||
<h1 className="font-heading text-3xl font-semibold">
|
||||
สร้างภาพ Overview
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
สร้างและดาวน์โหลดภาพ PNG ขนาด 3000×3000 โดยไม่เปลี่ยนภาพใน Guide
|
||||
</p>
|
||||
</header>
|
||||
<OverviewImageGenerator
|
||||
characters={catalog.characters.map((character) => ({
|
||||
key: character.key,
|
||||
name: character.name,
|
||||
aliases: character.aliases,
|
||||
element: character.element,
|
||||
rarity: character.rarity,
|
||||
weaponType: character.weaponType,
|
||||
imageUrl: character.imageUrl,
|
||||
nameCardImageUrl: character.nameCardImageUrl,
|
||||
}))}
|
||||
elementIconUrls={elementIconUrls}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ConstellationImageGenerator } from "@/components/admin/constellation-image-generator";
|
||||
import { getActiveCatalog } from "@/lib/guides/queries";
|
||||
|
||||
export default async function ConstellationImagePage() {
|
||||
const catalog = await getActiveCatalog();
|
||||
if (!catalog) redirect("/admin?sync=required");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<header>
|
||||
<p className="text-sm font-medium text-primary">Image generator</p>
|
||||
<h1 className="font-heading text-3xl font-semibold">สร้างภาพ Constellation</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
สร้างกราฟดาเมจ Constellation จาก Catalog โดยไม่บันทึกข้อมูลลง Guide
|
||||
</p>
|
||||
</header>
|
||||
<ConstellationImageGenerator characters={catalog.characters} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
|
||||
import { AdminHeader } from "@/components/admin/admin-header";
|
||||
import { ImageGeneratorNav } from "@/components/admin/image-generator-nav";
|
||||
import { getAdminSession } from "@/lib/auth/server";
|
||||
|
||||
export default async function ImageGeneratorLayout({
|
||||
children,
|
||||
}: LayoutProps<"/admin/image">) {
|
||||
await connection();
|
||||
if (!(await getAdminSession())) redirect("/admin/login");
|
||||
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<AdminHeader />
|
||||
<main className="mx-auto flex w-full max-w-[96rem] flex-col gap-6 p-4 sm:p-6 lg:flex-row lg:p-8">
|
||||
<ImageGeneratorNav />
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { OverviewImageGenerator } from "@/components/admin/overview-image-generator";
|
||||
import { getActiveCatalog } from "@/lib/guides/queries";
|
||||
import { elementOrder } from "@/lib/overview-image";
|
||||
|
||||
export default async function OverviewImagePage() {
|
||||
const catalog = await getActiveCatalog();
|
||||
if (!catalog) redirect("/admin?sync=required");
|
||||
|
||||
const elementIconUrls = Object.fromEntries(
|
||||
elementOrder.map((element) => [
|
||||
element,
|
||||
catalog.characters.find(
|
||||
(character) =>
|
||||
character.element === element && character.elementIconUrl,
|
||||
)?.elementIconUrl ?? null,
|
||||
]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<header>
|
||||
<p className="text-sm font-medium text-primary">Image generator</p>
|
||||
<h1 className="font-heading text-3xl font-semibold">
|
||||
สร้างภาพ Overview
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
สร้างและดาวน์โหลดภาพ PNG ขนาด 3000×3000 โดยไม่เปลี่ยนภาพใน Guide
|
||||
</p>
|
||||
</header>
|
||||
<OverviewImageGenerator
|
||||
characters={catalog.characters.map((character) => ({
|
||||
key: character.key,
|
||||
name: character.name,
|
||||
aliases: character.aliases,
|
||||
element: character.element,
|
||||
rarity: character.rarity,
|
||||
weaponType: character.weaponType,
|
||||
imageUrl: character.imageUrl,
|
||||
nameCardImageUrl: character.nameCardImageUrl,
|
||||
}))}
|
||||
elementIconUrls={elementIconUrls}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function ImageGeneratorPage() {
|
||||
redirect("/admin/image/overview");
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { TeamImageGenerator } from "@/components/admin/team-image-generator";
|
||||
import { getActiveCatalog } from "@/lib/guides/queries";
|
||||
|
||||
export default async function TeamImagePage() {
|
||||
const catalog = await getActiveCatalog();
|
||||
if (!catalog) redirect("/admin?sync=required");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<header>
|
||||
<p className="text-sm font-medium text-primary">Image generator</p>
|
||||
<h1 className="font-heading text-3xl font-semibold">สร้างภาพ Team Calc</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
สร้างภาพ Team แบบเดียวกับหน้า Guide โดยไม่บันทึกข้อมูลลง Guide
|
||||
</p>
|
||||
</header>
|
||||
<TeamImageGenerator
|
||||
characters={catalog.characters}
|
||||
weapons={catalog.weapons}
|
||||
artifacts={catalog.artifacts}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { WeaponImageGenerator } from "@/components/admin/weapon-image-generator";
|
||||
import { getActiveCatalog } from "@/lib/guides/queries";
|
||||
|
||||
export default async function WeaponImagePage() {
|
||||
const catalog = await getActiveCatalog();
|
||||
if (!catalog) redirect("/admin?sync=required");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<header>
|
||||
<p className="text-sm font-medium text-primary">Image generator</p>
|
||||
<h1 className="font-heading text-3xl font-semibold">สร้างภาพ Weapon</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
สร้างภาพเปรียบเทียบอาวุธจาก Catalog โดยไม่บันทึกข้อมูลลง Guide
|
||||
</p>
|
||||
</header>
|
||||
<WeaponImageGenerator
|
||||
characters={catalog.characters}
|
||||
weapons={catalog.weapons}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -79,11 +79,11 @@ export function AdminHeader() {
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
pathname === "/admin/generate/image" ? "secondary" : "ghost"
|
||||
pathname.startsWith("/admin/image") ? "secondary" : "ghost"
|
||||
}
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link href="/admin/generate/image" />}
|
||||
render={<Link href="/admin/image/overview" />}
|
||||
>
|
||||
<ImageIcon data-icon="inline-start" />
|
||||
<span className="hidden sm:inline">Image Generator</span>
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon, RotateCcwIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import * as z from "zod";
|
||||
|
||||
import {
|
||||
ImageCatalogPicker,
|
||||
type ImageCatalogCharacter,
|
||||
} from "@/components/admin/image-generator-fields";
|
||||
import { useImageGeneratorDraft } from "@/components/admin/use-image-generator-draft";
|
||||
import {
|
||||
ConstellationImage,
|
||||
type ConstellationImageGroup,
|
||||
type ConstellationImageGuide,
|
||||
} from "@/components/public/constellation-image";
|
||||
import { GuideImageExport } from "@/components/public/guide-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 { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const rowSchema = z.object({
|
||||
level: z.number().int().min(1).max(6),
|
||||
cumulativePercent: z.string(),
|
||||
isGreat: z.boolean(),
|
||||
});
|
||||
|
||||
const draftSchema = z.object({
|
||||
characterKey: z.string(),
|
||||
note: z.string(),
|
||||
rows: z.array(rowSchema).max(6).refine((rows) => rows.length === 0 || rows.length === 6),
|
||||
});
|
||||
|
||||
type ConstellationDraft = z.infer<typeof draftSchema>;
|
||||
|
||||
const INITIAL_CONSTELLATION_DRAFT: ConstellationDraft = {
|
||||
characterKey: "",
|
||||
note: "",
|
||||
rows: [],
|
||||
};
|
||||
|
||||
const constellationRows = (): ConstellationDraft["rows"] =>
|
||||
Array.from({ length: 6 }, (_, index) => ({
|
||||
level: index + 1,
|
||||
cumulativePercent: "100",
|
||||
isGreat: false,
|
||||
}));
|
||||
|
||||
const parseConstellationDraft = (value: unknown): ConstellationDraft | null => {
|
||||
const parsed = draftSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
};
|
||||
|
||||
function validPercent(value: string): boolean {
|
||||
const number = Number(value);
|
||||
return value.trim().length > 0 && Number.isFinite(number) && number >= -9999 && number <= 9999;
|
||||
}
|
||||
|
||||
export function ConstellationImageGenerator({
|
||||
characters,
|
||||
}: {
|
||||
characters: ImageCatalogCharacter[];
|
||||
}) {
|
||||
const [draft, setDraft, resetDraft] = useImageGeneratorDraft(
|
||||
"buzz-sheet:admin-image:constellation",
|
||||
INITIAL_CONSTELLATION_DRAFT,
|
||||
parseConstellationDraft,
|
||||
);
|
||||
const character = characters.find((item) => item.key === draft.characterKey);
|
||||
const valid = Boolean(character && draft.rows.every((row) => validPercent(row.cumulativePercent)));
|
||||
|
||||
const guide = useMemo<ConstellationImageGuide>(() => ({
|
||||
coverUrl: character?.imageUrl ?? "",
|
||||
character: {
|
||||
name: character?.name ?? "Character",
|
||||
splashImageUrl: character?.splashImageUrl ?? character?.imageUrl ?? null,
|
||||
},
|
||||
}), [character]);
|
||||
|
||||
const group = useMemo<ConstellationImageGroup>(() => ({
|
||||
note: draft.note.trim() || null,
|
||||
rows: draft.rows,
|
||||
}), [draft.note, draft.rows]);
|
||||
|
||||
return (
|
||||
<div className="grid items-start gap-6 xl:grid-cols-[minmax(24rem,1fr)_minmax(28rem,1fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ข้อมูล Constellation</CardTitle>
|
||||
<CardDescription>กรอกดาเมจสะสม C1–C6 เทียบกับ C0 ที่ 100%</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>ยังไม่มี Constellation</EmptyTitle>
|
||||
<EmptyDescription>สร้างรายการ C1–C6 เพื่อเริ่มกรอกเปอร์เซ็นต์ดาเมจ</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button type="button" onClick={() => setDraft((current) => ({ ...current, rows: constellationRows() }))}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
สร้าง Constellation
|
||||
</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 }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="constellation-image-note">โน้ตท้ายภาพ (ถ้ามี)</FieldLabel>
|
||||
<Textarea
|
||||
id="constellation-image-note"
|
||||
value={draft.note}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, note: event.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup>
|
||||
{draft.rows.map((row, index) => (
|
||||
<Field key={row.level} data-invalid={!validPercent(row.cumulativePercent)}>
|
||||
<div className="grid grid-cols-[3rem_minmax(0,1fr)_auto] items-center gap-3">
|
||||
<FieldLabel htmlFor={`constellation-image-c${row.level}`}>
|
||||
C{row.level}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`constellation-image-c${row.level}`}
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={row.cumulativePercent}
|
||||
aria-invalid={!validPercent(row.cumulativePercent)}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
rows: current.rows.map((item, rowIndex) => rowIndex === index
|
||||
? { ...item, cumulativePercent: event.target.value }
|
||||
: item),
|
||||
}))}
|
||||
/>
|
||||
<Field orientation="horizontal">
|
||||
<Switch
|
||||
id={`constellation-image-great-${row.level}`}
|
||||
checked={row.isGreat}
|
||||
onCheckedChange={(isGreat) => setDraft((current) => ({
|
||||
...current,
|
||||
rows: current.rows.map((item, rowIndex) => rowIndex === index
|
||||
? { ...item, isGreat }
|
||||
: item),
|
||||
}))}
|
||||
/>
|
||||
<FieldLabel htmlFor={`constellation-image-great-${row.level}`}>เด่น</FieldLabel>
|
||||
</Field>
|
||||
</div>
|
||||
</Field>
|
||||
))}
|
||||
</FieldGroup>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ภาพส่งออก</CardTitle>
|
||||
<CardDescription>ใช้กราฟเดียวกับ Constellation export ใน Guide</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!draft.rows.length ? (
|
||||
<Empty className="border">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>ยังไม่มี Preview</EmptyTitle>
|
||||
<EmptyDescription>สร้าง Constellation แล้วกรอกข้อมูลเพื่อดูภาพส่งออก</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
<GuideImageExport
|
||||
fileName={`${character?.name ?? "character"}-Constellations.png`}
|
||||
width={1500}
|
||||
height={1500}
|
||||
captureClassName="p-0"
|
||||
modeSelection="inline"
|
||||
disabled={!valid}
|
||||
>
|
||||
<ConstellationImage guide={guide} group={group} />
|
||||
</GuideImageExport>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
import {
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
FuzzyCombobox,
|
||||
} from "@/components/ui/combobox";
|
||||
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ImageCatalogItem {
|
||||
key: string;
|
||||
name: string;
|
||||
aliases: string[];
|
||||
imageUrl: string;
|
||||
rarity: number | null;
|
||||
}
|
||||
|
||||
export interface ImageCatalogCharacter extends ImageCatalogItem {
|
||||
element: string | null;
|
||||
weaponType: string | null;
|
||||
splashImageUrl: string | null;
|
||||
}
|
||||
|
||||
export interface ImageCatalogWeapon extends ImageCatalogItem {
|
||||
weaponType: string | null;
|
||||
}
|
||||
|
||||
export type ImageCatalogArtifact = ImageCatalogItem;
|
||||
|
||||
export function ImageCatalogPicker<Item extends ImageCatalogItem>({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
invalid = false,
|
||||
}: {
|
||||
items: Item[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
invalid?: boolean;
|
||||
}) {
|
||||
const selected = items.find((item) => item.key === value) ?? null;
|
||||
|
||||
return (
|
||||
<FuzzyCombobox
|
||||
items={items}
|
||||
value={selected}
|
||||
onValueChange={(item) => onChange(item?.key ?? "")}
|
||||
itemToStringLabel={(item) => item.name}
|
||||
itemToStringValue={(item) => item.key}
|
||||
searchNames={(item) => [item.name, ...item.aliases]}
|
||||
autoHighlight
|
||||
>
|
||||
<ComboboxInput
|
||||
className="w-full"
|
||||
placeholder={placeholder}
|
||||
showClear
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
<ComboboxContent className="min-w-72">
|
||||
<ComboboxEmpty>ไม่พบรายการ</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item) => (
|
||||
<ComboboxItem key={item.key} value={item}>
|
||||
<span
|
||||
className={cn(
|
||||
"relative size-8 shrink-0 overflow-hidden rounded-md",
|
||||
rarityGradientClass(item.rarity),
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={item.imageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="32px"
|
||||
className="object-contain"
|
||||
/>
|
||||
</span>
|
||||
<span>{item.name}</span>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</FuzzyCombobox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
ChartNoAxesColumnIncreasingIcon,
|
||||
ImageIcon,
|
||||
SwordsIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const links = [
|
||||
{ href: "/admin/image/overview", label: "Overview", icon: ImageIcon },
|
||||
{ href: "/admin/image/team", label: "Team", icon: UsersIcon },
|
||||
{ href: "/admin/image/weapon", label: "Weapon", icon: SwordsIcon },
|
||||
{
|
||||
href: "/admin/image/constellation",
|
||||
label: "Constellation",
|
||||
icon: ChartNoAxesColumnIncreasingIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export function ImageGeneratorNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="min-w-0 lg:w-48 lg:shrink-0">
|
||||
<nav
|
||||
aria-label="Image generator navigation"
|
||||
className="flex min-w-0 gap-1 overflow-x-auto lg:sticky lg:top-4 lg:flex-col"
|
||||
>
|
||||
{links.map(({ href, label, icon: Icon }) => (
|
||||
<Button
|
||||
key={href}
|
||||
variant={pathname === href ? "secondary" : "ghost"}
|
||||
className="justify-start"
|
||||
nativeButton={false}
|
||||
render={<Link href={href} />}
|
||||
>
|
||||
<Icon data-icon="inline-start" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import * as z from "zod";
|
||||
|
||||
import { ImageCatalogPicker, type ImageCatalogArtifact, 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 { 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 { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { supportsWeaponType } from "@/lib/catalog/lunaris";
|
||||
import { teamConstellationLabel, teamRefinementLabel } from "@/lib/guides/team-tools";
|
||||
|
||||
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: ImageCatalogCharacter[]; weapons: ImageCatalogWeapon[]; artifacts: ImageCatalogArtifact[] }) {
|
||||
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><ImageCatalogPicker items={characters} value={member.characterKey} placeholder="ค้นหาตัวละคร…" onChange={(characterKey) => updateMember(index, { ...emptyMember(), characterKey })} /></Field>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field><FieldLabel>อาวุธ</FieldLabel><ImageCatalogPicker items={compatibleWeapons} value={member.weaponKey} placeholder="ค้นหาอาวุธที่รองรับ…" onChange={(weaponKey) => updateMember(index, { weaponKey, refinement: "1" })} /></Field>
|
||||
<Field><FieldLabel>Refinement</FieldLabel><Select value={member.refinement} onValueChange={(value) => { if (value) updateMember(index, { refinement: value as TeamMemberDraft["refinement"] }); }}><SelectTrigger><SelectValue>{teamRefinementLabel(member.refinement)}</SelectValue></SelectTrigger><SelectContent><SelectGroup>{["1", "2", "3", "4", "5", "1-5"].map((value) => <SelectItem key={value} value={value}>{teamRefinementLabel(value)}</SelectItem>)}</SelectGroup></SelectContent></Select></Field>
|
||||
<Field><FieldLabel>Artifact</FieldLabel><ImageCatalogPicker items={artifacts} value={member.artifactKey} placeholder="ค้นหา Artifact…" onChange={(artifactKey) => updateMember(index, { artifactKey })} /></Field>
|
||||
<Field><FieldLabel>Artifact ชุดที่ 2 (ถ้ามี)</FieldLabel><ImageCatalogPicker items={artifacts.filter((item) => item.key !== member.artifactKey)} value={member.secondArtifactKey} placeholder="ค้นหา Artifact…" onChange={(secondArtifactKey) => updateMember(index, { secondArtifactKey })} /></Field>
|
||||
<Field><FieldLabel>Constellation</FieldLabel><Select value={member.constellation} onValueChange={(value) => { if (value) updateMember(index, { constellation: value as TeamMemberDraft["constellation"] }); }}><SelectTrigger><SelectValue>{teamConstellationLabel(member.constellation)}</SelectValue></SelectTrigger><SelectContent><SelectGroup>{["0", "1", "2", "3", "4", "5", "6", "0-6"].map((value) => <SelectItem key={value} value={value}>{teamConstellationLabel(value)}</SelectItem>)}</SelectGroup></SelectContent></Select></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>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
parseImageGeneratorDraft,
|
||||
serializeImageGeneratorDraft,
|
||||
} from "@/lib/image-generator-draft";
|
||||
|
||||
export function useImageGeneratorDraft<Value>(
|
||||
key: string,
|
||||
initialValue: Value,
|
||||
parse: (value: unknown) => Value | null,
|
||||
) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
try {
|
||||
const serialized = window.localStorage.getItem(key);
|
||||
if (serialized) {
|
||||
const parsed = parseImageGeneratorDraft(serialized, parse);
|
||||
if (parsed) setValue(parsed);
|
||||
}
|
||||
} catch {
|
||||
// A corrupt or unavailable browser draft should not block the generator.
|
||||
}
|
||||
setHydrated(true);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [key, parse]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
key,
|
||||
serializeImageGeneratorDraft(value),
|
||||
);
|
||||
} catch {
|
||||
// Browser storage is optional; the current generator remains usable.
|
||||
}
|
||||
}, [hydrated, key, value]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Browser storage is optional.
|
||||
}
|
||||
setValue(initialValue);
|
||||
}, [initialValue, key]);
|
||||
|
||||
return [value, setValue, reset] as const;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,19 @@ import type { PublicGuide } from "@/lib/guides/queries";
|
||||
import { constellationChartRows } from "@/lib/guides/export-image";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ConstellationImageGuide = Pick<PublicGuide, "coverUrl"> & {
|
||||
character: Pick<PublicGuide["character"], "name" | "splashImageUrl">;
|
||||
};
|
||||
|
||||
export interface ConstellationImageGroup {
|
||||
note: string | null;
|
||||
rows: Array<{
|
||||
level: number;
|
||||
cumulativePercent: string;
|
||||
isGreat: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
function percentage(value: number): string {
|
||||
return `${value.toLocaleString("en-US", { maximumFractionDigits: 2 })}%`;
|
||||
}
|
||||
@@ -14,8 +27,8 @@ export function ConstellationImage({
|
||||
guide,
|
||||
group,
|
||||
}: {
|
||||
guide: PublicGuide;
|
||||
group: PublicGuide["constellationGroups"][number];
|
||||
guide: ConstellationImageGuide;
|
||||
group: ConstellationImageGroup;
|
||||
}) {
|
||||
const rows = constellationChartRows(group.rows);
|
||||
const background = guide.character.splashImageUrl ?? guide.coverUrl;
|
||||
|
||||
@@ -37,6 +37,33 @@ import {
|
||||
} from "@/lib/guides/team-search";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TeamExportData {
|
||||
title: string;
|
||||
rotationSeconds: string | number;
|
||||
combo: string;
|
||||
members: Array<{
|
||||
position: number;
|
||||
constellation: string;
|
||||
refinement: string;
|
||||
damage: string | number;
|
||||
character: {
|
||||
key: string;
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
element: string | null;
|
||||
rarity: number | null;
|
||||
};
|
||||
weapon: {
|
||||
key: string;
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
rarity: number | null;
|
||||
} | null;
|
||||
artifact: TeamArtifact | null;
|
||||
secondArtifact: TeamArtifact | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
type Team = PublicGuide["teams"][number];
|
||||
const teamSearchTargetLabels: Record<TeamSearchTarget, string> = {
|
||||
all: "ทั้งหมด",
|
||||
@@ -179,7 +206,7 @@ export function TeamArtifactIcon({
|
||||
);
|
||||
}
|
||||
|
||||
function TeamExportImage({ team }: { team: Team }) {
|
||||
export function TeamExportImage({ team }: { team: TeamExportData }) {
|
||||
const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0);
|
||||
const seconds = Number(team.rotationSeconds);
|
||||
return (
|
||||
|
||||
@@ -21,12 +21,26 @@ import {
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import type { PublicGuide } from "@/lib/guides/queries";
|
||||
|
||||
type WeaponExportGuide = Pick<PublicGuide, "name" | "coverUrl"> & {
|
||||
export type WeaponExportGuide = Pick<PublicGuide, "name" | "coverUrl"> & {
|
||||
character: Pick<PublicGuide["character"], "name" | "splashImageUrl">;
|
||||
};
|
||||
|
||||
type WeaponGroup = PublicGuide["weaponGroups"][number];
|
||||
type DamageMetric = "overallPercent" | "personalPercent";
|
||||
export interface WeaponGroup {
|
||||
name: string | null;
|
||||
note: string | null;
|
||||
rows: Array<{
|
||||
id: string;
|
||||
refinement: string;
|
||||
overallPercent: string | null;
|
||||
personalPercent: string | null;
|
||||
note: string | null;
|
||||
weapon: {
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
export type DamageMetric = "overallPercent" | "personalPercent";
|
||||
|
||||
function formattedPercent(value: string | null): string {
|
||||
if (value === null) return "-%";
|
||||
|
||||
@@ -10,9 +10,53 @@ import {
|
||||
import { MaterialPoster } from "@/components/public/material-poster";
|
||||
import { ConstellationImage } from "@/components/public/constellation-image";
|
||||
import { WeaponComparisonImage } from "@/components/public/weapon-image-export";
|
||||
import { TeamExportImage, type TeamExportData } from "@/components/public/team-search";
|
||||
import type { PublicGuide } from "@/lib/guides/queries";
|
||||
|
||||
describe("image export controls", () => {
|
||||
it.each([1, 4])("renders the shared Team Calc image with %i member(s)", (memberCount) => {
|
||||
const members: TeamExportData["members"] = Array.from({ length: memberCount }, (_, position) => ({
|
||||
position,
|
||||
constellation: String(position),
|
||||
refinement: "1",
|
||||
damage: "1000",
|
||||
character: {
|
||||
key: `character-${position}`,
|
||||
name: `Character ${position + 1}`,
|
||||
imageUrl: "/character.png",
|
||||
element: "Fire",
|
||||
rarity: 5,
|
||||
},
|
||||
weapon: {
|
||||
key: `weapon-${position}`,
|
||||
name: `Weapon ${position + 1}`,
|
||||
imageUrl: "/weapon.png",
|
||||
rarity: 5,
|
||||
},
|
||||
artifact: {
|
||||
key: `artifact-${position}`,
|
||||
name: `Artifact ${position + 1}`,
|
||||
imageUrl: "/artifact.png",
|
||||
rarity: 5,
|
||||
},
|
||||
secondArtifact: null,
|
||||
}));
|
||||
const team: TeamExportData = {
|
||||
title: "Generator Team",
|
||||
rotationSeconds: "20",
|
||||
combo: "E > Q",
|
||||
members,
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(<TeamExportImage team={team} />);
|
||||
|
||||
expect(html).toContain("Generator Team");
|
||||
expect(html).toContain("Rotation duration: 20 seconds");
|
||||
expect(html).toContain("E > Q");
|
||||
expect(html).toContain(`DPS: ${memberCount * 50}`);
|
||||
expect(html.match(/Character \d/gu)).toHaveLength(memberCount);
|
||||
});
|
||||
|
||||
it("omits the placeholder condition from weapon export images", () => {
|
||||
const guide = {
|
||||
character: { name: "Raiden", splashImageUrl: "/splash.png" },
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
IMAGE_GENERATOR_DRAFT_VERSION,
|
||||
parseImageGeneratorDraft,
|
||||
serializeImageGeneratorDraft,
|
||||
} from "./image-generator-draft";
|
||||
|
||||
describe("image generator drafts", () => {
|
||||
const parseTitle = (value: unknown) => {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const title = (value as { title?: unknown }).title;
|
||||
return typeof title === "string" ? { title } : null;
|
||||
};
|
||||
|
||||
it("round trips a versioned draft", () => {
|
||||
const serialized = serializeImageGeneratorDraft({ title: "Hyperbloom" });
|
||||
expect(parseImageGeneratorDraft(serialized, parseTitle)).toEqual({ title: "Hyperbloom" });
|
||||
});
|
||||
|
||||
it("rejects corrupt, incompatible, and invalid drafts", () => {
|
||||
expect(parseImageGeneratorDraft("not-json", parseTitle)).toBeNull();
|
||||
expect(parseImageGeneratorDraft(JSON.stringify({ version: IMAGE_GENERATOR_DRAFT_VERSION + 1, value: { title: "Old" } }), parseTitle)).toBeNull();
|
||||
expect(parseImageGeneratorDraft(JSON.stringify({ version: IMAGE_GENERATOR_DRAFT_VERSION, value: { title: 42 } }), parseTitle)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
export const IMAGE_GENERATOR_DRAFT_VERSION = 1;
|
||||
|
||||
export function serializeImageGeneratorDraft<Value>(value: Value): string {
|
||||
return JSON.stringify({ version: IMAGE_GENERATOR_DRAFT_VERSION, value });
|
||||
}
|
||||
|
||||
export function parseImageGeneratorDraft<Value>(
|
||||
serialized: string,
|
||||
parse: (value: unknown) => Value | null,
|
||||
): Value | null {
|
||||
try {
|
||||
const envelope = JSON.parse(serialized) as {
|
||||
version?: unknown;
|
||||
value?: unknown;
|
||||
};
|
||||
if (envelope.version !== IMAGE_GENERATOR_DRAFT_VERSION) return null;
|
||||
return parse(envelope.value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ConstellationImageGenerator } from "@/components/admin/constellation-image-generator";
|
||||
import { TeamImageGenerator } from "@/components/admin/team-image-generator";
|
||||
import { WeaponImageGenerator } from "@/components/admin/weapon-image-generator";
|
||||
|
||||
describe("admin image generator empty states", () => {
|
||||
it("starts Team with a create action instead of an empty form", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TeamImageGenerator characters={[]} weapons={[]} artifacts={[]} />,
|
||||
);
|
||||
expect(html).toContain("ยังไม่มี Team");
|
||||
expect(html).toContain("เพิ่ม Team");
|
||||
expect(html).not.toContain("team-image-title");
|
||||
});
|
||||
|
||||
it("starts Weapon with a create action instead of an empty row", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<WeaponImageGenerator characters={[]} weapons={[]} />,
|
||||
);
|
||||
expect(html).toContain("ยังไม่มีรายการอาวุธ");
|
||||
expect(html).toContain("เพิ่มรายการอาวุธ");
|
||||
expect(html).not.toContain("weapon-image-condition");
|
||||
});
|
||||
|
||||
it("starts Constellation with one action that creates C1–C6", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ConstellationImageGenerator characters={[]} />,
|
||||
);
|
||||
expect(html).toContain("ยังไม่มี Constellation");
|
||||
expect(html).toContain("สร้าง Constellation");
|
||||
expect(html).not.toContain("constellation-image-c1");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user