feat : add image gen

This commit is contained in:
2026-09-06 00:31:51 +07:00 Unverified
parent 2de07c4917
commit 201a1478fc
20 changed files with 1172 additions and 1 deletions
@@ -0,0 +1,899 @@
"use client";
import Image from "next/image";
import {
CropIcon,
DownloadIcon,
ImageIcon,
RotateCcwIcon,
RotateCwIcon,
ZoomInIcon,
ZoomOutIcon,
} from "lucide-react";
import {
useEffect,
useRef,
useState,
type ChangeEvent,
type Ref,
} from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
Cropper,
CropperCropArea,
CropperDescription,
CropperImage,
} from "@/components/ui/cropper";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { elementIconFilter } from "@/lib/catalog/element";
import { rarityGradientClass } from "@/lib/catalog/rarity";
import {
accentGradient,
elementOrder,
normalizeHex,
overviewFileName,
overviewRoles,
weaponTypeIconPath,
type OverviewRole,
} from "@/lib/overview-image";
import { cn } from "@/lib/utils";
type Character = {
key: string;
name: string;
aliases: string[];
element: string | null;
rarity: number | null;
weaponType: string | null;
imageUrl: string;
nameCardImageUrl: string | null;
};
type PendingCrop = {
url: string;
};
type CropArea = { x: number; y: number; width: number; height: number };
const DISCLAIMER =
"ไม่สามารถเอาไปเปรียบเทียบกับการคำนวณของ TC ท่านอื่นๆได้\nเพราะแต่ละคนใช้สแตทในการคำนวณที่ไม่เหมือนกัน\nและตารางที่เห็นอาจมีการเปลี่ยนแปลงได้ในอนาคต";
function OverviewPoster({
artworkUrl,
character,
role,
topColors,
bottomColors,
nameCardCrop,
nameCardSize,
elementIconUrls,
captureRef,
}: {
artworkUrl: string | null;
character: Character | null;
role: OverviewRole;
topColors: [string, string];
bottomColors: [string, string];
nameCardCrop: CropArea | null;
nameCardSize: { width: number; height: number } | null;
elementIconUrls: Record<string, string | null>;
captureRef?: Ref<HTMLDivElement>;
}) {
const weaponIcon = weaponTypeIconPath(character?.weaponType ?? null);
const normalizedBottom = normalizeHex(bottomColors[1]) ?? "#E9A51A";
const nameSize = (character?.name.length ?? 0) > 16 ? "5.6cqw" : "7.2cqw";
return (
<div
ref={captureRef}
className="relative isolate aspect-square size-full overflow-hidden bg-black text-white [container-type:inline-size]"
style={{ fontFamily: '"Genshin Overview", sans-serif' }}
>
{character?.nameCardImageUrl ? (
nameCardCrop && nameCardSize ? (
<Image
src={character.nameCardImageUrl}
alt=""
width={nameCardSize.width}
height={nameCardSize.height}
loading="eager"
sizes="1024px"
className="absolute max-w-none"
style={{
width: `${(nameCardSize.width / nameCardCrop.width) * 100}%`,
height: `${(nameCardSize.height / nameCardCrop.height) * 100}%`,
left: `${(-nameCardCrop.x / nameCardCrop.width) * 100}%`,
top: `${(-nameCardCrop.y / nameCardCrop.height) * 100}%`,
}}
/>
) : (
<Image
src={character.nameCardImageUrl}
alt=""
fill
loading="eager"
sizes="1024px"
className="object-cover"
/>
)
) : null}
{artworkUrl ? (
<>
<Image
src={artworkUrl}
alt=""
fill
unoptimized
loading="eager"
sizes="1024px"
className="object-cover"
/>
<Image
src={artworkUrl}
alt=""
fill
unoptimized
loading="eager"
sizes="1024px"
className="object-cover mix-blend-color-burn"
/>
</>
) : (
<div className="absolute inset-0 grid place-items-center bg-muted text-muted-foreground">
<ImageIcon aria-hidden="true" className="size-[12cqw]" />
</div>
)}
<Image
src="/img/fade.png"
alt=""
fill
unoptimized
loading="eager"
sizes="1024px"
className="object-fill"
/>
<div
className="absolute inset-0"
style={{
backgroundImage: accentGradient(...topColors),
maskImage: "url('/img/aura-up.png')",
maskMode: "alpha",
maskPosition: "center",
maskRepeat: "no-repeat",
maskSize: "100% 100%",
WebkitMaskImage: "url('/img/aura-up.png')",
WebkitMaskPosition: "center",
WebkitMaskRepeat: "no-repeat",
WebkitMaskSize: "100% 100%",
}}
/>
<div
className="absolute inset-0"
style={{
backgroundImage: accentGradient(...bottomColors),
maskImage: "url('/img/aura-down.png')",
maskMode: "alpha",
maskPosition: "center",
maskRepeat: "no-repeat",
maskSize: "100% 100%",
WebkitMaskImage: "url('/img/aura-down.png')",
WebkitMaskPosition: "center",
WebkitMaskRepeat: "no-repeat",
WebkitMaskSize: "100% 100%",
}}
/>
<div
className="absolute inset-x-0 top-0 h-[1.35cqw]"
style={{ backgroundImage: accentGradient(...topColors) }}
/>
<div
className="absolute inset-x-0 bottom-0 h-[1.35cqw]"
style={{ backgroundImage: accentGradient(...bottomColors) }}
/>
<Image
src="/img/badge.png"
alt=""
fill
unoptimized
loading="eager"
sizes="1024px"
className="object-fill"
/>
<div className="absolute top-[4.43cqw] left-0 flex h-[6.2cqw] items-center gap-[0.45cqw] text-[4.4cqw] leading-none">
{weaponIcon ? (
<Image
src={weaponIcon}
alt=""
width={128}
height={128}
loading="eager"
className="ml-[0.3cqw] size-[5.65cqw] object-contain brightness-0 invert"
/>
) : null}
<span>{role}</span>
</div>
{character ? (
<div className="absolute inset-x-[5cqw] top-[69cqw] flex flex-col items-center text-center">
<p
className="max-w-full truncate leading-none font-normal tracking-[0.04em]"
style={{
fontSize: `clamp(4rem, 8cqi, 2.5rem)`,
textShadow: `0 0 0.8cqw ${normalizedBottom}`,
}}
>
{character.name.toLocaleUpperCase("en")}
</p>
<div className="mt-[2.1cqw] flex items-center justify-center gap-[1.5cqw]">
{elementOrder.map((element) => {
const url = elementIconUrls[element];
const active = element === character.element;
return url ? (
<Image
key={element}
src={url}
alt=""
width={72}
height={72}
loading="eager"
sizes="5cqw"
className={cn(
"size-[4.7cqw] object-contain",
!active && "brightness-0 invert opacity-30",
)}
style={
active
? {
filter: `${elementIconFilter(element) ?? ""} drop-shadow(0 0 0.5cqw ${normalizedBottom})`,
}
: undefined
}
/>
) : null;
})}
</div>
<p className="mt-[2.3cqw] whitespace-pre-line text-[2.15cqw] leading-[1.15]">
{DISCLAIMER}
</p>
</div>
) : null}
</div>
);
}
function ColorField({
id,
label,
value,
onChange,
}: {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
}) {
const invalid = !normalizeHex(value);
return (
<Field data-invalid={invalid}>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<div className="flex gap-2">
<Input
aria-label={`${label} picker`}
type="color"
value={normalizeHex(value) ?? "#000000"}
onChange={(event) => onChange(event.target.value.toUpperCase())}
className="w-14 shrink-0 p-1"
/>
<Input
id={id}
value={value}
maxLength={7}
aria-invalid={invalid}
placeholder="#E9A51A"
onChange={(event) => onChange(event.target.value)}
/>
</div>
{invalid ? <FieldError>กรุณาใส่สีรูปแบบ #RRGGBB</FieldError> : null}
</Field>
);
}
export function OverviewImageGenerator({
characters,
elementIconUrls,
}: {
characters: Character[];
elementIconUrls: Record<string, string | null>;
}) {
const captureRef = useRef<HTMLDivElement>(null);
const [character, setCharacter] = useState<Character | null>(null);
const [artworkUrl, setArtworkUrl] = useState<string | null>(null);
const [artworkError, setArtworkError] = useState<string | null>(null);
const [pendingCrop, setPendingCrop] = useState<PendingCrop | null>(null);
const [cropArea, setCropArea] = useState<CropArea | null>(null);
const [cropZoom, setCropZoom] = useState(1);
const [nameCardCrop, setNameCardCrop] = useState<CropArea | null>(null);
const [nameCardDraftCrop, setNameCardDraftCrop] =
useState<CropArea | null>(null);
const [nameCardSize, setNameCardSize] = useState<{
width: number;
height: number;
} | null>(null);
const [nameCardCropOpen, setNameCardCropOpen] = useState(false);
const [nameCardZoom, setNameCardZoom] = useState(1);
const [role, setRole] = useState<OverviewRole>("Main DPS");
const [topColorStart, setTopColorStart] = useState("#E9A51A");
const [topColorEnd, setTopColorEnd] = useState("#FFD84A");
const [bottomColorStart, setBottomColorStart] = useState("#E9A51A");
const [bottomColorEnd, setBottomColorEnd] = useState("#FFD84A");
const [exporting, setExporting] = useState(false);
useEffect(
() => () => {
if (artworkUrl) URL.revokeObjectURL(artworkUrl);
},
[artworkUrl],
);
useEffect(() => {
const url = character?.nameCardImageUrl;
if (!url) return;
let cancelled = false;
const image = new window.Image();
image.onload = () => {
if (cancelled) return;
const size = Math.min(image.naturalWidth, image.naturalHeight);
setNameCardSize({
width: image.naturalWidth,
height: image.naturalHeight,
});
setNameCardCrop({
x: (image.naturalWidth - size) / 2,
y: (image.naturalHeight - size) / 2,
width: size,
height: size,
});
};
image.onerror = () => {
if (!cancelled) toast.error("ไม่สามารถโหลดภาพ Namecard ได้");
};
image.src = url;
return () => {
cancelled = true;
};
}, [character]);
useEffect(
() => () => {
if (pendingCrop) URL.revokeObjectURL(pendingCrop.url);
},
[pendingCrop],
);
async function selectArtwork(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const url = URL.createObjectURL(file);
try {
await new Promise<void>((resolve, reject) => {
const image = new window.Image();
image.onload = () => resolve();
image.onerror = reject;
image.src = url;
});
setArtworkError(null);
setCropArea(null);
setCropZoom(1);
setPendingCrop({ url });
} catch {
URL.revokeObjectURL(url);
setArtworkError("ไม่สามารถอ่านไฟล์ภาพนี้ได้");
}
}
async function applyCrop() {
if (!pendingCrop || !cropArea) return;
try {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const candidate = new window.Image();
candidate.onload = () => resolve(candidate);
candidate.onerror = reject;
candidate.src = pendingCrop.url;
});
const canvas = document.createElement("canvas");
canvas.width = Math.round(cropArea.width);
canvas.height = Math.round(cropArea.height);
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas is unavailable");
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.drawImage(
image,
cropArea.x,
cropArea.y,
cropArea.width,
cropArea.height,
0,
0,
canvas.width,
canvas.height,
);
const blob = await new Promise<Blob>((resolve, reject) =>
canvas.toBlob(
(value) => (value ? resolve(value) : reject(new Error("Crop failed"))),
"image/png",
),
);
setArtworkUrl(URL.createObjectURL(blob));
setPendingCrop(null);
setArtworkError(null);
} catch {
setArtworkError("ไม่สามารถครอปไฟล์ภาพนี้ได้");
setPendingCrop(null);
}
}
async function rotateArtwork(degrees: -90 | 90) {
if (!pendingCrop) return;
try {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const candidate = new window.Image();
candidate.onload = () => resolve(candidate);
candidate.onerror = reject;
candidate.src = pendingCrop.url;
});
const canvas = document.createElement("canvas");
canvas.width = image.naturalHeight;
canvas.height = image.naturalWidth;
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas is unavailable");
context.translate(canvas.width / 2, canvas.height / 2);
context.rotate((degrees * Math.PI) / 180);
context.drawImage(
image,
-image.naturalWidth / 2,
-image.naturalHeight / 2,
);
const blob = await new Promise<Blob>((resolve, reject) =>
canvas.toBlob(
(value) =>
value ? resolve(value) : reject(new Error("Rotation failed")),
"image/png",
),
);
setCropArea(null);
setCropZoom(1);
setPendingCrop({ url: URL.createObjectURL(blob) });
} catch {
toast.error("หมุนภาพไม่สำเร็จ กรุณาลองอีกครั้ง");
}
}
async function download() {
const node = captureRef.current;
if (
!node ||
!character ||
!artworkUrl ||
!normalizeHex(topColorStart) ||
!normalizeHex(topColorEnd) ||
!normalizeHex(bottomColorStart) ||
!normalizeHex(bottomColorEnd)
)
return;
setExporting(true);
try {
const { domToBlob, waitUntilLoad } = await import("modern-screenshot");
await Promise.all([document.fonts.ready, waitUntilLoad(node)]);
const blob = await domToBlob(node, {
backgroundColor: "#000000",
type: "image/png",
width: 1024,
height: 1024,
scale: 4,
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = overviewFileName(character.key, role);
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
toast.success("ดาวน์โหลดภาพ Overview แล้ว");
} catch {
toast.error("สร้างภาพไม่สำเร็จ กรุณาลองอีกครั้ง");
} finally {
setExporting(false);
}
}
const ready = Boolean(
character &&
artworkUrl &&
normalizeHex(topColorStart) &&
normalizeHex(topColorEnd) &&
normalizeHex(bottomColorStart) &&
normalizeHex(bottomColorEnd) &&
weaponTypeIconPath(character.weaponType),
);
return (
<div className="grid items-start gap-6 lg:grid-cols-[minmax(18rem,24rem)_minmax(0,1fr)]">
<Card>
<CardHeader>
<CardTitle>ตั้งค่าภาพ</CardTitle>
<CardDescription>ไฟล์ภาพจะใช้เฉพาะในเบราว์เซอร์นี้</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel>ตัวละคร</FieldLabel>
<Combobox
items={characters}
value={character}
onValueChange={(value) => {
setCharacter(value);
setNameCardCrop(null);
setNameCardSize(null);
setNameCardCropOpen(false);
}}
itemToStringLabel={(item) => item.name}
itemToStringValue={(item) => item.key}
filter={(item, query) =>
[item.name, ...item.aliases]
.join(" ")
.toLocaleLowerCase("th")
.includes(query.trim().toLocaleLowerCase("th"))
}
autoHighlight
>
<ComboboxInput className="w-full" placeholder="ค้นหาตัวละคร…" />
<ComboboxContent>
<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>
</Combobox>
<FieldDescription>
ชื่อ ธาตุ และประเภทอาวุธจะมาจาก Catalog
</FieldDescription>
</Field>
<Field>
<FieldLabel>พื้นหลัง Namecard</FieldLabel>
<Button
type="button"
variant="outline"
disabled={!character?.nameCardImageUrl || !nameCardSize}
onClick={() => {
setNameCardDraftCrop(null);
setNameCardZoom(1);
setNameCardCropOpen(true);
}}
>
<CropIcon data-icon="inline-start" />
เลือกตำแหน่งพื้นหลัง
</Button>
<FieldDescription>
ใช้ Namecard ของตัวละครใต้ภาพโปร่งใส
</FieldDescription>
</Field>
<Field data-invalid={Boolean(artworkError)}>
<FieldLabel htmlFor="overview-artwork">ภาพตัวละคร 1:1</FieldLabel>
<Input
id="overview-artwork"
type="file"
accept="image/png,image/jpeg,image/webp"
aria-invalid={Boolean(artworkError)}
onChange={selectArtwork}
/>
<FieldDescription>
ทุกภาพจะเปิดหน้าต่างให้ครอป ซูม และจัดตำแหน่งก่อนใช้งาน
</FieldDescription>
{artworkError ? <FieldError>{artworkError}</FieldError> : null}
</Field>
<Field>
<FieldLabel>Role</FieldLabel>
<ToggleGroup
value={[role]}
onValueChange={(value) => {
const selected = value[0] as OverviewRole | undefined;
if (selected) setRole(selected);
}}
variant="outline"
className="flex-wrap justify-start"
>
{overviewRoles.map((value) => (
<ToggleGroupItem key={value} value={value}>
{value}
</ToggleGroupItem>
))}
</ToggleGroup>
</Field>
<ColorField
id="overview-top-color-start"
label="สีขอบบน เริ่มต้น"
value={topColorStart}
onChange={setTopColorStart}
/>
<ColorField
id="overview-top-color-end"
label="สีขอบบน สิ้นสุด"
value={topColorEnd}
onChange={setTopColorEnd}
/>
<ColorField
id="overview-bottom-color-start"
label="สีขอบล่าง เริ่มต้น"
value={bottomColorStart}
onChange={setBottomColorStart}
/>
<ColorField
id="overview-bottom-color-end"
label="สีขอบล่าง สิ้นสุด"
value={bottomColorEnd}
onChange={setBottomColorEnd}
/>
</FieldGroup>
</CardContent>
<CardFooter>
<Button className="w-full" disabled={!ready || exporting} onClick={download}>
{exporting ? (
<Spinner data-icon="inline-start" />
) : (
<DownloadIcon data-icon="inline-start" />
)}
{exporting ? "กำลังสร้าง PNG…" : "ดาวน์โหลด PNG 4096×4096"}
</Button>
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle>ตัวอย่าง</CardTitle>
<CardDescription>ภาพดาวน์โหลดจะใช้เลย์เอาต์เดียวกับตัวอย่าง</CardDescription>
</CardHeader>
<CardContent>
<div className="mx-auto aspect-square w-full max-w-3xl overflow-hidden rounded-lg border">
<OverviewPoster
artworkUrl={artworkUrl}
character={character}
role={role}
topColors={[topColorStart, topColorEnd]}
bottomColors={[bottomColorStart, bottomColorEnd]}
nameCardCrop={nameCardCrop}
nameCardSize={nameCardSize}
elementIconUrls={elementIconUrls}
/>
</div>
</CardContent>
</Card>
<div
aria-hidden="true"
className="pointer-events-none fixed top-0 -left-[10000px] size-256"
>
<OverviewPoster
artworkUrl={artworkUrl}
character={character}
role={role}
topColors={[topColorStart, topColorEnd]}
bottomColors={[bottomColorStart, bottomColorEnd]}
nameCardCrop={nameCardCrop}
nameCardSize={nameCardSize}
elementIconUrls={elementIconUrls}
captureRef={captureRef}
/>
</div>
<Dialog
open={Boolean(pendingCrop)}
onOpenChange={(open) => {
if (!open) setPendingCrop(null);
}}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>ครอปภาพเป็น 1:1</DialogTitle>
<DialogDescription>
เลื่อนตำแหน่งภาพให้ส่วนที่ต้องการอยู่ภายในกรอบสี่เหลี่ยม
</DialogDescription>
</DialogHeader>
{pendingCrop ? (
<div className="flex flex-col gap-5">
<Cropper
key={pendingCrop.url}
image={pendingCrop.url}
aspectRatio={1}
zoom={cropZoom}
onCropChange={setCropArea}
onZoomChange={setCropZoom}
className="h-96 rounded-lg bg-black sm:h-120"
>
<CropperDescription>
ลากภาพหรือใช้ปุ่มลูกศรเพื่อเลือกพื้นที่ครอปสี่เหลี่ยม
</CropperDescription>
<CropperImage />
<CropperCropArea />
</Cropper>
<div className="flex justify-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => rotateArtwork(-90)}
>
<RotateCcwIcon data-icon="inline-start" />
หมุนซ้าย
</Button>
<Button
type="button"
variant="outline"
onClick={() => rotateArtwork(90)}
>
<RotateCwIcon data-icon="inline-start" />
หมุนขวา
</Button>
</div>
<Field>
<FieldLabel htmlFor="crop-zoom">ซูม</FieldLabel>
<div className="flex items-center gap-3">
<ZoomOutIcon aria-hidden="true" className="shrink-0 opacity-60" />
<Input
id="crop-zoom"
type="range"
min={1}
max={3}
step={0.1}
value={cropZoom}
onChange={(event) =>
setCropZoom(Number(event.target.value))
}
/>
<ZoomInIcon aria-hidden="true" className="shrink-0 opacity-60" />
</div>
</Field>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setPendingCrop(null)}>
ยกเลิก
</Button>
<Button disabled={!cropArea} onClick={applyCrop}>
<CropIcon data-icon="inline-start" />
ใช้ภาพที่ครอป
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={nameCardCropOpen} onOpenChange={setNameCardCropOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>ครอปพื้นหลัง Namecard</DialogTitle>
<DialogDescription>
ลากและซูม Namecard เพื่อเลือกพื้นที่ด้านหลังตัวละคร
</DialogDescription>
</DialogHeader>
{character?.nameCardImageUrl ? (
<div className="flex flex-col gap-5">
<Cropper
image={character.nameCardImageUrl}
aspectRatio={1}
zoom={nameCardZoom}
onCropChange={setNameCardDraftCrop}
onZoomChange={setNameCardZoom}
className="h-96 rounded-lg bg-black sm:h-120"
>
<CropperDescription>
ลากภาพหรือใช้ปุ่มลูกศรเพื่อเลือกพื้นหลังสี่เหลี่ยม
</CropperDescription>
<CropperImage />
<CropperCropArea />
</Cropper>
<Field>
<FieldLabel htmlFor="namecard-zoom">ซูม</FieldLabel>
<div className="flex items-center gap-3">
<ZoomOutIcon aria-hidden="true" className="shrink-0 opacity-60" />
<Input
id="namecard-zoom"
type="range"
min={1}
max={3}
step={0.1}
value={nameCardZoom}
onChange={(event) =>
setNameCardZoom(Number(event.target.value))
}
/>
<ZoomInIcon aria-hidden="true" className="shrink-0 opacity-60" />
</div>
</Field>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setNameCardCropOpen(false)}>
ยกเลิก
</Button>
<Button
disabled={!nameCardDraftCrop}
onClick={() => {
if (!nameCardDraftCrop) return;
setNameCardCrop(nameCardDraftCrop);
setNameCardCropOpen(false);
}}
>
<CropIcon data-icon="inline-start" />
ใช้ตำแหน่งนี้
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}