Files
buzz-sheet/components/admin/overview-image-generator.tsx
T
gunshiz d3e5e6c04f
CI / Verify (push) Successful in 1m11s
CI / Build immutable images and deploy (push) Successful in 5m17s
feat : add image generator
2026-09-06 02:22:19 +07:00

1452 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Image, { getImageProps } from "next/image";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
sortableKeyboardCoordinates,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
CropIcon,
DownloadIcon,
GripVerticalIcon,
ImageIcon,
PlusIcon,
RotateCcwIcon,
RotateCwIcon,
Trash2Icon,
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,
detectThemeColorsFromPixels,
elementGlowColor,
elementGradientColors,
elementOrder,
normalizeHex,
overviewFileName,
overviewRoles,
overviewBlendModes,
type OverviewBlendMode,
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;
target: "artwork" | "background";
};
type Composition = {
backgroundUrl: string | null;
fadeColor: string;
fadeTint: number;
auraUpOffset: number;
};
type BlendLayer = {
id: number;
mode: OverviewBlendMode;
amount: number;
};
type CropArea = { x: number; y: number; width: number; height: number };
const blendModeItems: Array<{ value: OverviewBlendMode; label: string }> = [
...overviewBlendModes,
];
const DISCLAIMER =
"ไม่สามารถเอาไปเปรียบเทียบกับการคำนวณของ TC ท่านอื่นๆได้\nเพราะแต่ละคนใช้สแตทในการคำนวณที่ไม่เหมือนกัน\nและตารางที่เห็นอาจมีการเปลี่ยนแปลงได้ในอนาคต";
function OverviewPoster({
artworkUrl,
character,
role,
topColors,
bottomColors,
elementColors,
elementGradientAngle,
blendLayers,
nameCardCrop,
nameCardSize,
elementIconUrls,
captureRef,
composition,
}: {
artworkUrl: string | null;
character: Character | null;
role: OverviewRole;
topColors: [string, string];
bottomColors: [string, string];
elementColors: [string, string];
elementGradientAngle: number;
blendLayers: BlendLayer[];
nameCardCrop: CropArea | null;
nameCardSize: { width: number; height: number } | null;
elementIconUrls: Record<string, string | null>;
captureRef?: Ref<HTMLDivElement>;
composition: Composition;
}) {
const weaponIcon = weaponTypeIconPath(character?.weaponType ?? null);
const z = {
character: 1,
fade: 2,
aura: 3,
badge: 4,
text: 5,
} as const;
const titleRef = useRef<HTMLParagraphElement>(null);
const disclaimerRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
let cancelled = false;
const fit = () => {
if (cancelled || !titleRef.current || !disclaimerRef.current) return;
const title = titleRef.current;
const disclaimer = disclaimerRef.current;
const width = title.parentElement!.getBoundingClientRect().width;
title.style.fontSize = `${width / 10}px`;
if (title.scrollWidth > width) {
title.style.fontSize = `${((width / 10) * width) / title.scrollWidth}px`;
}
const textWidth = title.getBoundingClientRect().width;
const context = document.createElement("canvas").getContext("2d");
if (!context) return;
context.font = '100px "Genshin Overview"';
const longest = Math.max(
...DISCLAIMER.split("\n").map(
(line) => context.measureText(line).width,
),
);
disclaimer.style.width = `${textWidth}px`;
disclaimer.style.fontSize = `${Math.min(width * 0.024, (textWidth * 100) / longest)}px`;
};
const observer = new ResizeObserver(fit);
if (titleRef.current?.parentElement)
observer.observe(titleRef.current.parentElement);
void document.fonts.ready.then(fit);
return () => {
cancelled = true;
observer.disconnect();
};
}, [character?.name]);
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' }}
>
{composition.backgroundUrl ? (
<Image
src={composition.backgroundUrl}
alt=""
fill
unoptimized
loading="eager"
className="object-cover"
/>
) : 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}%`,
opacity: 0.7,
}}
/>
) : (
<Image
src={character.nameCardImageUrl}
alt=""
fill
loading="eager"
sizes="1024px"
className="object-cover opacity-70"
/>
)
) : null}
{artworkUrl ? (
<div className="absolute inset-0" style={{ zIndex: z.character }}>
<Image
src={artworkUrl}
alt=""
fill
unoptimized
loading="eager"
sizes="1024px"
className="object-cover"
/>
{[...blendLayers].reverse().map((layer) => (
<Image
key={layer.id}
src={artworkUrl}
alt=""
fill
unoptimized
loading="eager"
sizes="1024px"
className="object-cover"
style={{
mixBlendMode: layer.mode,
opacity: layer.amount / 100,
}}
/>
))}
</div>
) : (
<div className="absolute inset-0 grid place-items-center bg-muted text-muted-foreground">
<ImageIcon aria-hidden="true" className="size-[12cqw]" />
</div>
)}
<div
className="absolute inset-x-0 -top-[3cqw] bottom-0"
style={{ zIndex: z.fade }}
>
<Image
src="/img/fade.png"
alt=""
fill
unoptimized
loading="eager"
sizes="1024px"
className="object-fill"
/>
<div
className="absolute inset-0"
style={{
backgroundColor: normalizeHex(composition.fadeColor) ?? "#0E0200",
opacity: composition.fadeTint / 100,
mask: "url('/img/fade.png') center / 100% 100% no-repeat",
maskMode: "alpha",
WebkitMask: "url('/img/fade.png') center / 100% 100% no-repeat",
}}
/>
</div>
<div
className="absolute inset-0 pointer-events-none"
style={{ zIndex: z.aura }}
>
<div
className="absolute inset-0"
style={{
backgroundImage: accentGradient(...topColors),
transform: `translateY(-${composition.auraUpOffset}%)`,
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) }}
/>
</div>
<div
className="absolute inset-0 pointer-events-none"
style={{ zIndex: z.badge }}
>
<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 font-semibold"
style={{ fontFamily: '"Kanit Overview", sans-serif' }}
>
{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>
</div>
{character ? (
<div
className="absolute inset-x-[5cqw] top-[71cqw] flex flex-col items-center text-center"
style={{ zIndex: z.text }}
>
<p
ref={titleRef}
className="w-fit max-w-full whitespace-nowrap leading-none font-normal tracking-[0.04em]"
style={{ fontSize: "9cqw" }}
>
{character.name.toLocaleUpperCase("en")}
</p>
<div className="mt-[0.8cqw] flex items-center justify-center gap-[0.65cqw]">
{elementOrder.map((element) => {
const url = elementIconUrls[element];
const active = element === character.element;
if (!url) return null;
if (active) {
const { props: maskImageProps } = getImageProps({
src: url,
alt: "",
width: 72,
height: 72,
});
return (
<span
key={element}
className="relative size-[3.5cqw] shrink-0"
style={{
filter: `drop-shadow(0 0 0.43cqw ${elementGlowColor(element)}80)`,
}}
>
<Image
src={url}
alt=""
fill
loading="eager"
sizes="5cqw"
className="object-contain"
style={{ filter: elementIconFilter(element) }}
/>
<span
className="absolute inset-0"
style={{
backgroundImage: `linear-gradient(${elementGradientAngle}deg, ${normalizeHex(elementColors[0]) ?? "#FFFFFF"}, ${normalizeHex(elementColors[1]) ?? "#FFFFFF"})`,
maskImage: `url(${JSON.stringify(maskImageProps.src)})`,
maskMode: "alpha",
maskPosition: "center",
maskRepeat: "no-repeat",
maskSize: "contain",
WebkitMaskImage: `url(${JSON.stringify(maskImageProps.src)})`,
WebkitMaskPosition: "center",
WebkitMaskRepeat: "no-repeat",
WebkitMaskSize: "contain",
}}
/>
</span>
);
}
return (
<Image
key={element}
src={url}
alt=""
width={72}
height={72}
loading="eager"
sizes="5cqw"
className="size-[3.5cqw] object-contain brightness-0 invert opacity-30"
/>
);
})}
</div>
<p
ref={disclaimerRef}
className="mt-[1.5cqw] whitespace-pre 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>
);
}
function SortableBlendLayerRow({
layer,
onUpdate,
onRemove,
}: {
layer: BlendLayer;
onUpdate: (patch: Partial<Omit<BlendLayer, "id">>) => void;
onRemove: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: layer.id });
const selectedMode = blendModeItems.find((mode) => mode.value === layer.mode);
return (
<div
ref={setNodeRef}
className={cn(
"flex items-start gap-2 rounded-lg border bg-background p-3",
isDragging && "relative z-10 opacity-60 shadow-md",
)}
style={{ transform: CSS.Transform.toString(transform), transition }}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="touch-none cursor-grab active:cursor-grabbing"
aria-label="ลากเพื่อจัดลำดับ Blend mode"
{...attributes}
{...listeners}
>
<GripVerticalIcon data-icon="inline-start" />
</Button>
<div className="min-w-0 flex-1">
<Combobox
items={blendModeItems}
value={selectedMode}
onValueChange={(value) => {
if (value) onUpdate({ mode: value.value });
}}
itemToStringLabel={(item) => item.label}
itemToStringValue={(item) => item.value}
filter={(item, query) =>
`${item.label} ${item.value}`
.toLocaleLowerCase("en")
.includes(query.trim().toLocaleLowerCase("en"))
}
>
<ComboboxInput className="w-full" placeholder="เลือก Blend mode…" />
<ComboboxContent>
<ComboboxEmpty>ไม่พบ Blend mode</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item.value} value={item}>
{item.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<Field className="mt-3">
<FieldLabel htmlFor={`overview-blend-amount-${layer.id}`}>
ความเข้ม {layer.amount}%
</FieldLabel>
<Input
id={`overview-blend-amount-${layer.id}`}
type="range"
min={0}
max={100}
step={5}
value={layer.amount}
onChange={(event) => onUpdate({ amount: Number(event.target.value) })}
/>
</Field>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="ลบ Blend mode"
onClick={onRemove}
>
<Trash2Icon data-icon="inline-start" />
</Button>
</div>
);
}
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 [elementColorStart, setElementColorStart] = useState("#FFFFFF");
const [elementColorEnd, setElementColorEnd] = useState("#FFFFFF");
const [elementGradientAngle, setElementGradientAngle] = useState(101);
const [blendLayers, setBlendLayers] = useState<BlendLayer[]>([
{ id: 1, mode: "color-burn", amount: 100 },
]);
const nextBlendLayerId = useRef(2);
const blendLayerSensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const [exporting, setExporting] = useState(false);
const [backgroundUrl, setBackgroundUrl] = useState<string | null>(null);
const [fadeColor, setFadeColor] = useState("#0E0200");
const [fadeTint, setFadeTint] = useState(100);
const [auraUpOffset, setAuraUpOffset] = useState(4);
const composition: Composition = {
backgroundUrl,
fadeColor,
fadeTint,
auraUpOffset,
};
useEffect(
() => () => {
if (backgroundUrl) URL.revokeObjectURL(backgroundUrl);
},
[backgroundUrl],
);
useEffect(
() => () => {
if (artworkUrl) URL.revokeObjectURL(artworkUrl);
},
[artworkUrl],
);
function detectArtworkTheme(
source: CanvasImageSource,
width: number,
height: number,
) {
const maxDimension = 64;
const scale = Math.min(1, maxDimension / Math.max(width, height));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(width * scale));
canvas.height = Math.max(1, Math.round(height * scale));
const context = canvas.getContext("2d");
if (!context) return;
context.drawImage(source, 0, 0, width, height, 0, 0, canvas.width, canvas.height);
const theme = detectThemeColorsFromPixels(
context.getImageData(0, 0, canvas.width, canvas.height).data,
canvas.width,
canvas.height,
);
setTopColorStart(theme.top[0]);
setTopColorEnd(theme.top[1]);
setBottomColorStart(theme.bottom[0]);
setBottomColorEnd(theme.bottom[1]);
}
function addBlendLayer() {
setBlendLayers((current) => [
...current,
{
id: nextBlendLayerId.current++,
mode: "normal",
amount: 100,
},
]);
}
function updateBlendLayer(id: number, patch: Partial<Omit<BlendLayer, "id">>) {
setBlendLayers((current) =>
current.map((layer) => (layer.id === id ? { ...layer, ...patch } : layer)),
);
}
function removeBlendLayer(id: number) {
setBlendLayers((current) => current.filter((layer) => layer.id !== id));
}
function handleBlendLayerDragEnd({ active, over }: DragEndEvent) {
if (!over || active.id === over.id) return;
setBlendLayers((current) => {
const oldIndex = current.findIndex(
(layer) => String(layer.id) === String(active.id),
);
const newIndex = current.findIndex(
(layer) => String(layer.id) === String(over.id),
);
if (oldIndex === -1 || newIndex === -1) return current;
return arrayMove(current, oldIndex, newIndex);
});
}
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>,
target: "artwork" | "background" = "artwork",
) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const url = URL.createObjectURL(file);
try {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const image = new window.Image();
image.onload = () => resolve(image);
image.onerror = reject;
image.src = url;
});
if (target === "artwork") {
detectArtworkTheme(image, image.naturalWidth, image.naturalHeight);
}
setArtworkError(null);
setCropArea(null);
setCropZoom(1);
setPendingCrop({ url, target });
} 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,
);
if (pendingCrop.target === "artwork") {
detectArtworkTheme(canvas, 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",
),
);
if (pendingCrop.target === "background")
setBackgroundUrl(URL.createObjectURL(blob));
else 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),
target: pendingCrop.target,
});
} catch {
toast.error("หมุนภาพไม่สำเร็จ กรุณาลองอีกครั้ง");
}
}
async function download() {
const node = captureRef.current;
if (
!node ||
!character ||
!artworkUrl ||
!normalizeHex(topColorStart) ||
!normalizeHex(topColorEnd) ||
!normalizeHex(bottomColorStart) ||
!normalizeHex(bottomColorEnd) ||
!normalizeHex(elementColorStart) ||
!normalizeHex(elementColorEnd) ||
!normalizeHex(fadeColor)
)
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: 1000,
height: 1000,
scale: 3,
});
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) &&
normalizeHex(elementColorStart) &&
normalizeHex(elementColorEnd) &&
normalizeHex(fadeColor) &&
weaponTypeIconPath(character.weaponType),
);
return (
<div className="grid items-start gap-6 lg:grid-cols-[minmax(16rem,1fr)_minmax(16rem,1fr)_minmax(20rem,1.3fr)]">
<div className="contents">
<Card className="order-1 lg:col-start-1 lg:row-start-1">
<CardHeader>
<CardTitle>ตั้งค่าภาพ</CardTitle>
<CardDescription>ไฟล์ภาพจะใช้เฉพาะในเบราว์เซอร์นี้</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel>ตัวละคร</FieldLabel>
<Combobox
items={characters}
value={character}
onValueChange={(value) => {
if (value?.element !== character?.element) {
const [start, end] = elementGradientColors(value?.element);
setElementColorStart(start);
setElementColorEnd(end);
}
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={(event) => selectArtwork(event)}
/>
<FieldDescription>
ทุกภาพจะเปิดหน้าต่างให้ครอป ซูม และจัดตำแหน่งก่อนใช้งาน
</FieldDescription>
{artworkError ? <FieldError>{artworkError}</FieldError> : null}
</Field>
<Field>
<FieldLabel htmlFor="overview-background">
อัปโหลดพื้นหลัง
</FieldLabel>
<Input
id="overview-background"
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => selectArtwork(event, "background")}
/>
{backgroundUrl ? (
<Button
variant="outline"
onClick={() => setBackgroundUrl(null)}
>
กลับไปใช้ Namecard
</Button>
) : null}
<FieldDescription>
ครอปและซูมพื้นหลังเป็นสี่เหลี่ยมก่อนใช้งาน
</FieldDescription>
</Field>
</FieldGroup>
</CardContent>
</Card>
<Card className="order-3 lg:col-start-3 lg:row-span-2 lg:row-start-1">
<CardHeader>
<CardTitle>ธีมและเอฟเฟกต์</CardTitle>
<CardDescription>
สีขอบบนและล่างจะตรวจจากภาพตัวละครโดยอัตโนมัติเมื่ออัปโหลด
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<ColorField
id="overview-fade-color"
label="สี Fade"
value={fadeColor}
onChange={setFadeColor}
/>
<Field>
<FieldLabel htmlFor="overview-fade-tint">
ความเข้มสี Fade {fadeTint}%
</FieldLabel>
<Input
id="overview-fade-tint"
type="range"
min={0}
max={100}
step={1}
value={fadeTint}
onChange={(event) => setFadeTint(Number(event.target.value))}
/>
</Field>
<Field>
<FieldLabel htmlFor="overview-aura-up">
เลื่อน Aura ด้านบนขึ้น {auraUpOffset}%
</FieldLabel>
<Input
id="overview-aura-up"
type="range"
min={0}
max={7}
step={0.1}
value={auraUpOffset}
onChange={(event) =>
setAuraUpOffset(Number(event.target.value))
}
/>
</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>
<Field>
<FieldLabel>Blend mode</FieldLabel>
<DndContext
sensors={blendLayerSensors}
collisionDetection={closestCenter}
onDragEnd={handleBlendLayerDragEnd}
>
<SortableContext
items={blendLayers.map((layer) => layer.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-3">
{blendLayers.map((layer) => (
<SortableBlendLayerRow
key={layer.id}
layer={layer}
onUpdate={(patch) => updateBlendLayer(layer.id, patch)}
onRemove={() => removeBlendLayer(layer.id)}
/>
))}
</div>
</SortableContext>
</DndContext>
<div className="mt-3">
<Button type="button" variant="outline" onClick={addBlendLayer}>
<PlusIcon data-icon="inline-start" />
เพิ่ม Blend mode
</Button>
</div>
<FieldDescription>
เพิ่มได้หลายแบบ และลากที่ไอคอนเพื่อจัดลำดับการผสมสี
</FieldDescription>
</Field>
</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 3000×3000"}
</Button>
</CardFooter>
</Card>
</div>
<Card className="order-2 min-w-0 lg:col-start-2 lg:row-start-1">
<CardHeader>
<CardTitle>ตัวอย่าง</CardTitle>
<CardDescription>
ภาพดาวน์โหลดจะใช้เลย์เอาต์เดียวกับตัวอย่าง
</CardDescription>
</CardHeader>
<CardContent>
<div className="mx-auto aspect-square w-full max-w-3xl overflow-hidden rounded-lg border">
<OverviewPoster
composition={composition}
artworkUrl={artworkUrl}
character={character}
role={role}
topColors={[topColorStart, topColorEnd]}
bottomColors={[bottomColorStart, bottomColorEnd]}
elementColors={[elementColorStart, elementColorEnd]}
elementGradientAngle={elementGradientAngle}
blendLayers={blendLayers}
nameCardCrop={nameCardCrop}
nameCardSize={nameCardSize}
elementIconUrls={elementIconUrls}
/>
</div>
</CardContent>
</Card>
<Card className="order-4 lg:col-span-2 lg:col-start-1 lg:row-start-2">
<CardHeader>
<CardTitle>สีธีม</CardTitle>
<CardDescription>
สีขอบบนและล่างจะตรวจจากภาพตัวละครโดยอัตโนมัติเมื่ออัปโหลด และแก้ไขเองได้
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<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}
/>
<ColorField
id="overview-element-color-start"
label="สีไล่ระดับธาตุ เริ่มต้น"
value={elementColorStart}
onChange={setElementColorStart}
/>
<ColorField
id="overview-element-color-end"
label="สีไล่ระดับธาตุ สิ้นสุด"
value={elementColorEnd}
onChange={setElementColorEnd}
/>
<Field>
<FieldLabel htmlFor="overview-element-gradient-angle">
มุมสีไล่ระดับธาตุ {elementGradientAngle}°
</FieldLabel>
<Input
id="overview-element-gradient-angle"
type="range"
min={0}
max={360}
step={1}
value={elementGradientAngle}
onChange={(event) =>
setElementGradientAngle(Number(event.target.value))
}
/>
</Field>
</FieldGroup>
</CardContent>
</Card>
<div
aria-hidden="true"
className="pointer-events-none fixed top-0 -left-[10000px] order-5 size-[1000px]"
>
<OverviewPoster
composition={composition}
artworkUrl={artworkUrl}
character={character}
role={role}
topColors={[topColorStart, topColorEnd]}
bottomColors={[bottomColorStart, bottomColorEnd]}
elementColors={[elementColorStart, elementColorEnd]}
elementGradientAngle={elementGradientAngle}
blendLayers={blendLayers}
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>
);
}