Files
buzz-sheet/components/admin/overview-image-generator.tsx
T
gunshiz f311d3c417
CI / Build immutable images and deploy (push) Canceled after 0s
CI / Verify (push) Canceled after 53s
fix(images) : retry failed CDN images through Cloudflare resizing
2026-10-06 15:15:39 +07:00

1639 lines
54 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 { getImageProps } from "next/image";
import Image from "@/components/ui/resilient-image";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useDraggable,
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,
useCallback,
useRef,
useState,
type ChangeEvent,
type CSSProperties,
} from "react";
import { toast } from "sonner";
import { CharacterSelect } from "@/components/admin/ui/catalog-select";
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 {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
ColorPicker,
ColorPickerEyeDropper,
ColorPickerHue,
ColorPickerSelection,
} from "@/components/ui/color-picker";
import { Slider } from "@/components/ui/slider";
import { Spinner } from "@/components/ui/spinner";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { elementIconFilter } from "@/lib/catalog/element";
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;
characterScale: number;
characterOffset: Position;
fadeOffset: Position;
};
type BlendLayer = {
id: number;
mode: OverviewBlendMode;
amount: number;
};
type CropArea = { x: number; y: number; width: number; height: number };
type Position = { x: number; y: number };
type PosterLayer = "character" | "fade";
const blendModeItems: Array<{ value: OverviewBlendMode; label: string }> = [
...overviewBlendModes,
];
const DISCLAIMER = "ไม่สามารถเอาไปเปรียบเทียบกับการคำนวณของ TC ท่านอื่นๆได้\nเพราะแต่ละคนใช้สแตทในการคำนวณที่ไม่เหมือนกัน\nและตารางที่เห็นอาจมีการเปลี่ยนแปลงได้ในอนาคต";
const DEFAULT_FADE_OFFSET: Position = { x: 0, y: 2 };
function DraggablePosterLayer({
id,
offset,
scale = 1,
interactive,
className,
style,
children,
}: {
id: PosterLayer;
offset: Position;
scale?: number;
interactive: boolean;
className?: string;
style?: CSSProperties;
children: React.ReactNode;
}) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id,
disabled: !interactive,
});
const temporaryTransform = transform
? ` translate3d(${transform.x}px, ${transform.y}px, 0)`
: "";
const dragProps = interactive ? { ...attributes, ...listeners } : {};
return (
<div
ref={setNodeRef}
className={className}
style={{
...style,
transform: `translate(${offset.x}%, ${offset.y}%)${temporaryTransform} scale(${scale})`,
}}
{...dragProps}
>
{children}
</div>
);
}
function OverviewPoster({
artworkUrl,
character,
role,
topColors,
bottomColors,
elementColors,
elementGradientAngle,
blendLayers,
nameCardCrop,
nameCardSize,
elementIconUrls,
composition,
interactive = false,
activeLayer,
onLayerMove,
}: {
artworkUrl: string | null;
character: Character | null;
role: string;
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>;
composition: Composition;
interactive?: boolean;
activeLayer?: PosterLayer;
onLayerMove?: (layer: PosterLayer, delta: Position) => void;
}) {
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 posterRef = useRef<HTMLDivElement>(null);
const posterSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
);
function handlePosterDragEnd({ active, delta }: DragEndEvent) {
if (!interactive || !onLayerMove) return;
const size = posterRef.current?.getBoundingClientRect().width;
if (!size || (active.id !== "character" && active.id !== "fade")) return;
onLayerMove(active.id, {
x: (delta.x / size) * 100,
y: (delta.y / size) * 100,
});
}
useEffect(() => {
let cancelled = false;
const fit = () => {
if (cancelled || !titleRef.current) return;
const title = titleRef.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 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 (
<DndContext sensors={posterSensors} onDragEnd={handlePosterDragEnd}>
<div
ref={posterRef}
className="relative isolate aspect-square size-full overflow-hidden bg-black text-white @container"
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 ? (
<DraggablePosterLayer
id="character"
offset={composition.characterOffset}
scale={composition.characterScale}
interactive={interactive && activeLayer === "character"}
className={cn(
"absolute inset-0 cursor-grab active:cursor-grabbing",
interactive && "touch-none",
)}
style={{ zIndex: z.character }}
>
<Image
src={artworkUrl}
alt=""
fill
unoptimized
loading="eager"
sizes="1024px"
className="object-contain"
/>
{[...blendLayers].reverse().map((layer) => (
<Image
key={layer.id}
src={artworkUrl}
alt=""
fill
unoptimized
loading="eager"
sizes="1024px"
className="object-contain"
style={{
mixBlendMode: layer.mode,
opacity: layer.amount / 100,
}}
/>
))}
</DraggablePosterLayer>
) : (
<div className="absolute inset-0 grid place-items-center bg-muted text-muted-foreground">
<ImageIcon aria-hidden="true" className="size-[12cqw]" />
</div>
)}
<DraggablePosterLayer
id="fade"
offset={composition.fadeOffset}
interactive={interactive && activeLayer === "fade"}
className={cn(
"absolute inset-x-0 top-[-3cqw] bottom-0",
activeLayer === "fade"
? "touch-none cursor-grab active:cursor-grabbing"
: "pointer-events-none",
)}
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",
}}
/>
</DraggablePosterLayer>
<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
className="mt-[1.5cqw] w-max whitespace-pre text-center text-[2.03167cqw] leading-[1.15]"
>
{DISCLAIMER}
</p>
</div>
) : null}
</div>
</DndContext>
);
}
function ColorField({
id,
label,
value,
onChange,
}: {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
}) {
const invalid = !normalizeHex(value);
const handlePickerChange = useCallback(
(color: unknown) => {
if (!Array.isArray(color)) return;
const hex = color
.slice(0, 3)
.map((channel) => Math.round(channel).toString(16).padStart(2, "0"))
.join("");
onChange(`#${hex.toUpperCase()}`);
},
[onChange],
);
return (
<Field data-invalid={invalid}>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<div className="flex gap-2">
<Popover>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
size="icon"
aria-label={`เลือก${label}`}
>
<span
className="size-5 rounded-sm border"
style={{ backgroundColor: normalizeHex(value) ?? "#000000" }}
/>
</Button>
}
/>
<PopoverContent align="start" className="w-64">
<ColorPicker
value={normalizeHex(value) ?? "#000000"}
onChange={handlePickerChange}
className="h-auto"
>
<ColorPickerSelection className="h-32" />
<ColorPickerHue />
<ColorPickerEyeDropper aria-label="ดูดสีจากหน้าจอ" />
</ColorPicker>
</PopoverContent>
</Popover>
<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>
<Slider
id={`overview-blend-amount-${layer.id}`}
min={0}
max={100}
step={5}
value={[layer.amount]}
onValueChange={(value) =>
onUpdate({
amount: typeof value === "number" ? value : value[0] ?? 0,
})
}
/>
</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 [customRoleText, setCustomRoleText] = useState("");
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(0);
const [auraUpOffset, setAuraUpOffset] = useState(4);
const [characterScale, setCharacterScale] = useState(100);
const [characterOffset, setCharacterOffset] = useState<Position>({
x: 0,
y: 0,
});
const [fadeOffset, setFadeOffset] = useState<Position>(DEFAULT_FADE_OFFSET);
const composition: Composition = {
backgroundUrl,
fadeColor,
fadeTint,
auraUpOffset,
characterScale: characterScale / 100,
characterOffset,
fadeOffset,
};
const displayRole =
role === "Custom" ? customRoleText.trim() || "Custom" : role;
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);
});
}
function movePosterLayer(layer: PosterLayer, delta: Position) {
const update = (current: Position): Position => ({
x: Number((current.x + delta.x).toFixed(2)),
y: Number((current.y + delta.y).toFixed(2)),
});
if (layer === "character") setCharacterOffset(update);
else setFadeOffset(update);
}
function resetPosterPositions() {
setCharacterOffset({ x: 0, y: 0 });
setFadeOffset(DEFAULT_FADE_OFFSET);
}
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);
setCharacterScale(100);
setArtworkUrl(await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
}));
URL.revokeObjectURL(url);
setArtworkError(null);
return;
}
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 croppedUrl = canvas.toDataURL("image/png");
if (pendingCrop.target === "background")
setBackgroundUrl(croppedUrl);
else setArtworkUrl(croppedUrl);
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, displayRole);
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>
<CharacterSelect
items={characters}
value={character?.key ?? ""}
placeholder="ค้นหาตัวละคร…"
onValueChange={(key) => {
const value = characters.find((item) => item.key === key) ?? null;
if (value?.element !== character?.element) {
const [start, end] = elementGradientColors(value?.element);
setElementColorStart(start);
setElementColorEnd(end);
}
setCharacter(value);
setNameCardCrop(null);
setNameCardSize(null);
setNameCardCropOpen(false);
}}
emptyLabel="ไม่พบตัวละคร"
/>
<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 data-disabled={!artworkUrl}>
<FieldLabel htmlFor="overview-artwork-zoom">
ซูมภาพตัวละคร {characterScale}%
</FieldLabel>
<Slider
id="overview-artwork-zoom"
min={25}
max={300}
step={1}
value={[characterScale]}
disabled={!artworkUrl}
onValueChange={(value) =>
setCharacterScale(
typeof value === "number" ? value : value[0] ?? 100,
)
}
/>
</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>
<Slider
id="overview-fade-tint"
min={0}
max={100}
step={1}
value={[fadeTint]}
onValueChange={(value) =>
setFadeTint(typeof value === "number" ? value : value[0] ?? 0)
}
/>
</Field>
<Field>
<FieldLabel htmlFor="overview-aura-up">
เลื่อน Aura ด้านบนขึ้น {auraUpOffset}%
</FieldLabel>
<Slider
id="overview-aura-up"
min={0}
max={7}
step={0.1}
value={[auraUpOffset]}
onValueChange={(value) =>
setAuraUpOffset(
typeof value === "number" ? value : value[0] ?? 0,
)
}
/>
</Field>
<Field>
<FieldLabel>ตำแหน่งภาพ</FieldLabel>
<Button
type="button"
variant="outline"
onClick={resetPosterPositions}
>
<RotateCcwIcon data-icon="inline-start" />
รีเซ็ตตำแหน่งตัวละครและ Fade
</Button>
<FieldDescription>
เลือกเลเยอร์ใต้ตัวอย่าง แล้วลากในตัวอย่างเพื่อเลื่อนตำแหน่ง
</FieldDescription>
</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>
{role === "Custom" ? (
<Field>
<FieldLabel htmlFor="overview-custom-role">
ข้อความ Custom Role
</FieldLabel>
<Input
id="overview-custom-role"
value={customRoleText}
maxLength={32}
placeholder="เช่น Buffer"
onChange={(event) => setCustomRoleText(event.target.value)}
/>
</Field>
) : null}
<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 className="flex flex-col gap-2">
<Button
className="w-full"
disabled={!ready || exporting}
onClick={() => void download()}
>
{exporting ? (
<Spinner data-icon="inline-start" />
) : (
<DownloadIcon data-icon="inline-start" />
)}
{exporting ? "กำลังส่งออก…" : "ดาวน์โหลด 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={displayRole}
topColors={[topColorStart, topColorEnd]}
bottomColors={[bottomColorStart, bottomColorEnd]}
elementColors={[elementColorStart, elementColorEnd]}
elementGradientAngle={elementGradientAngle}
blendLayers={blendLayers}
nameCardCrop={nameCardCrop}
nameCardSize={nameCardSize}
elementIconUrls={elementIconUrls}
interactive
activeLayer="character"
onLayerMove={movePosterLayer}
/>
</div>
<Field className="mt-4">
<FieldLabel htmlFor="overview-fade-y">
ตำแหน่ง Fade แนวตั้ง {fadeOffset.y}%
</FieldLabel>
<Slider
id="overview-fade-y"
min={-20}
max={20}
step={0.1}
value={[fadeOffset.y]}
onValueChange={(value) =>
setFadeOffset((current) => ({
...current,
y: typeof value === "number" ? value : value[0] ?? 2,
}))
}
/>
<FieldDescription>
ลากในตัวอย่างเพื่อเลื่อนตัวละคร และใช้แถบนี้เพื่อเลื่อน Fade
</FieldDescription>
</Field>
</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>
<Slider
id="overview-element-gradient-angle"
min={0}
max={360}
step={1}
value={[elementGradientAngle]}
onValueChange={(value) =>
setElementGradientAngle(
typeof value === "number" ? value : value[0] ?? 0,
)
}
/>
</Field>
</FieldGroup>
</CardContent>
</Card>
<div
ref={captureRef}
aria-hidden="true"
className="pointer-events-none fixed top-0 left-[-10000px] order-5 size-250"
>
<OverviewPoster
composition={composition}
artworkUrl={artworkUrl}
character={character}
role={displayRole}
topColors={[topColorStart, topColorEnd]}
bottomColors={[bottomColorStart, bottomColorEnd]}
elementColors={[elementColorStart, elementColorEnd]}
elementGradientAngle={elementGradientAngle}
blendLayers={blendLayers}
nameCardCrop={nameCardCrop}
nameCardSize={nameCardSize}
elementIconUrls={elementIconUrls}
/>
</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>
);
}