Files
buzz-sheet/components/admin/overview-image-generator.tsx
T
2026-09-14 18:02:18 +07:00

1636 lines
56 KiB
TypeScript

"use client";
import { LocaleBoundary, useTranslation } from "@/components/i18n/locale-provider";
import Image, { getImageProps } from "next/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,
useEffectEvent,
useRef,
useState,
type ChangeEvent,
type CSSProperties,
} 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,
FuzzyCombobox,
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;
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,
];
function DraggablePosterLayer({
id,
offset,
interactive,
className,
style,
children,
}: {
id: PosterLayer;
offset: Position;
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}`,
}}
{...dragProps}
>
{children}
</div>
);
}
function OverviewPoster({
artworkUrl,
character,
role,
topColors,
bottomColors,
elementColors,
elementGradientAngle,
blendLayers,
nameCardCrop,
nameCardSize,
elementIconUrls,
composition,
disclaimerFontSize,
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;
disclaimerFontSize: number | null;
interactive?: boolean;
activeLayer?: PosterLayer;
onLayerMove?: (layer: PosterLayer, delta: Position) => void;
}) {
const { e } = useTranslation();
const disclaimerText = e("disclaimer");
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);
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 || !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(
...disclaimerText.split("\n").map(
(line) => context.measureText(line).width,
),
);
disclaimer.style.width = "max-content";
disclaimer.style.textAlign = "center";
disclaimer.style.fontSize =
disclaimerFontSize === null
? `${Math.min(width * 0.024, (textWidth * 100) / longest)}px`
: `${disclaimerFontSize}cqw`;
};
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, disclaimerFontSize, disclaimerText]);
return (
<DndContext sensors={posterSensors} onDragEnd={handlePosterDragEnd}>
<div
ref={posterRef}
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 ? (
<DraggablePosterLayer
id="character"
offset={composition.characterOffset}
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-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,
}}
/>
))}
</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
ref={disclaimerRef}
className="mt-[1.5cqw] whitespace-pre text-center text-[2.15cqw] leading-[1.15]"
>
{disclaimerText}
</p>
</div>
) : null}
</div>
</DndContext>
);
}
function ColorField({
id,
label,
value,
onChange,
}: {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
}) {
const { t } = useTranslation();
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>{t("กรุณาใส่สีรูปแบบ #RRGGBB")}</FieldError> : null}
</Field>
);
}
function SortableBlendLayerRow({
layer,
onUpdate,
onRemove,
}: {
layer: BlendLayer;
onUpdate: (patch: Partial<Omit<BlendLayer, "id">>) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
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={t("ลากเพื่อจัดลำดับ 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={t("เลือก Blend mode…")} />
<ComboboxContent>
<ComboboxEmpty>{t("ไม่พบ 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}`}>
{t("ความเข้ม")}{" "}{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={t("ลบ Blend mode")}
onClick={onRemove}
>
<Trash2Icon data-icon="inline-start" />
</Button>
</div>
);
}
export function OverviewImageGenerator({
characters,
elementIconUrls,
}: {
characters: Character[];
elementIconUrls: Record<string, string | null>;
}) {
const { t, e, locale } = useTranslation();
const showNamecardError = useEffectEvent(() => toast.error(t("ไม่สามารถโหลดภาพ Namecard ได้")));
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 [exportLocale, setExportLocale] = useState<typeof locale | null>(null);
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 [characterOffset, setCharacterOffset] = useState<Position>({
x: 0,
y: 0,
});
const [fadeOffset, setFadeOffset] = useState<Position>({ x: 0, y: 0 });
const [activePosterLayer, setActivePosterLayer] =
useState<PosterLayer>("character");
const [disclaimerFontSize, setDisclaimerFontSize] = useState<number | null>(
null,
);
const composition: Composition = {
backgroundUrl,
fadeColor,
fadeTint,
auraUpOffset,
characterOffset,
fadeOffset,
};
const displayRole =
role === "Custom" ? customRoleText.trim() || "Custom" : role;
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);
});
}
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({ x: 0, y: 0 });
}
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) showNamecardError();
};
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(t("ไม่สามารถอ่านไฟล์ภาพนี้ได้"));
}
}
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(t("ไม่สามารถครอปไฟล์ภาพนี้ได้"));
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(t("หมุนภาพไม่สำเร็จ กรุณาลองอีกครั้ง"));
}
}
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;
setExportLocale(locale);
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;
const fileName = overviewFileName(character.key, displayRole);
link.download = fileName;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
toast.success(e("overviewDownloaded"));
} catch {
toast.error(e("exportError"));
} finally {
setExporting(false);
setExportLocale(null);
}
}
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>{t("ตั้งค่าภาพ")}</CardTitle>
<CardDescription>{t("ไฟล์ภาพจะใช้เฉพาะในเบราว์เซอร์นี้")}</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel>{t("ตัวละคร")}</FieldLabel>
<FuzzyCombobox
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}
searchNames={(item) => [item.name, ...(item.aliases ?? [])]}
autoHighlight
>
<ComboboxInput className="w-full" placeholder={t("ค้นหาตัวละคร…")} />
<ComboboxContent>
<ComboboxEmpty>{t("ไม่พบตัวละคร")}</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>
<FieldDescription>
{t("ชื่อ ธาตุ และประเภทอาวุธจะมาจาก Catalog")}</FieldDescription>
</Field>
<Field>
<FieldLabel>{t("พื้นหลัง Namecard")}</FieldLabel>
<Button
type="button"
variant="outline"
disabled={!character?.nameCardImageUrl || !nameCardSize}
onClick={() => {
setNameCardDraftCrop(null);
setNameCardZoom(1);
setNameCardCropOpen(true);
}}
>
<CropIcon data-icon="inline-start" />
{t("เลือกตำแหน่งพื้นหลัง")}</Button>
<FieldDescription>
{t("ใช้ Namecard ของตัวละครใต้ภาพโปร่งใส")}</FieldDescription>
</Field>
<Field data-invalid={Boolean(artworkError)}>
<FieldLabel htmlFor="overview-artwork">{t("ภาพตัวละคร 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>
{t("ทุกภาพจะเปิดหน้าต่างให้ครอป ซูม และจัดตำแหน่งก่อนใช้งาน")}</FieldDescription>
{artworkError ? <FieldError>{t(artworkError)}</FieldError> : null}
</Field>
<Field>
<FieldLabel htmlFor="overview-background">
{t("อัปโหลดพื้นหลัง")}</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)}
>
{t("กลับไปใช้ Namecard")}</Button>
) : null}
<FieldDescription>
{t("ครอปและซูมพื้นหลังเป็นสี่เหลี่ยมก่อนใช้งาน")}</FieldDescription>
</Field>
</FieldGroup>
</CardContent>
</Card>
<Card className="order-3 lg:col-start-3 lg:row-span-2 lg:row-start-1">
<CardHeader>
<CardTitle>{t("ธีมและเอฟเฟกต์")}</CardTitle>
<CardDescription>
{t("สีขอบบนและล่างจะตรวจจากภาพตัวละครโดยอัตโนมัติเมื่ออัปโหลด")}</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<ColorField
id="overview-fade-color"
label={t("สี Fade")}
value={fadeColor}
onChange={setFadeColor}
/>
<Field>
<FieldLabel htmlFor="overview-fade-tint">
{t("ความเข้มสี 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">
{t("เลื่อน 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>{t("ตำแหน่งภาพ")}</FieldLabel>
<Button
type="button"
variant="outline"
onClick={resetPosterPositions}
>
<RotateCcwIcon data-icon="inline-start" />
{t("รีเซ็ตตำแหน่งตัวละครและ Fade")}</Button>
<FieldDescription>
{t("เลือกเลเยอร์ใต้ตัวอย่าง แล้วลากในตัวอย่างเพื่อเลื่อนตำแหน่ง")}</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="overview-disclaimer-size">
{t("ขนาด Disclaimer")}{" "}{disclaimerFontSize === null ? t("ตามชื่ออัตโนมัติ") : `${disclaimerFontSize}cqw`}
</FieldLabel>
<div className="flex items-center gap-3">
<Input
id="overview-disclaimer-size"
type="range"
min={0.5}
max={5}
step={0.05}
value={disclaimerFontSize ?? 2.15}
onChange={(event) =>
setDisclaimerFontSize(Number(event.target.value))
}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={disclaimerFontSize === null}
onClick={() => setDisclaimerFontSize(null)}
>
{t("อัตโนมัติ")}</Button>
</div>
<FieldDescription>
{t("ค่าเริ่มต้นจะปรับให้พอดีกับความกว้างชื่ออัตโนมัติ")}</FieldDescription>
</Field>
<Field>
<FieldLabel>{t("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">
{t("ข้อความ Custom Role")}</FieldLabel>
<Input
id="overview-custom-role"
value={customRoleText}
maxLength={32}
placeholder={t("เช่น Buffer")}
onChange={(event) => setCustomRoleText(event.target.value)}
/>
</Field>
) : null}
<Field>
<FieldLabel>{t("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" />
{t("เพิ่ม Blend mode")}</Button>
</div>
<FieldDescription>
{t("เพิ่มได้หลายแบบ และลากที่ไอคอนเพื่อจัดลำดับการผสมสี")}</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 ? e("exporting") : e("downloadPng", { width: 3000, height: 3000 })}
</Button>
</CardFooter>
</Card>
</div>
<Card className="order-2 min-w-0 lg:col-start-2 lg:row-start-1">
<CardHeader>
<CardTitle>{t("ตัวอย่าง")}</CardTitle>
<CardDescription>
{t("ภาพดาวน์โหลดจะใช้เลย์เอาต์เดียวกับตัวอย่าง")}</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}
disclaimerFontSize={disclaimerFontSize}
interactive
activeLayer={activePosterLayer}
onLayerMove={movePosterLayer}
/>
</div>
<Field className="mt-4">
<FieldLabel>{t("เลือกเลเยอร์ที่จะเลื่อน")}</FieldLabel>
<ToggleGroup
value={[activePosterLayer]}
onValueChange={(value) => {
const selected = value[0] as PosterLayer | undefined;
if (selected) setActivePosterLayer(selected);
}}
variant="outline"
className="w-full"
>
<ToggleGroupItem value="character" className="flex-1">
{t("Char")}</ToggleGroupItem>
<ToggleGroupItem value="fade" className="flex-1">
{t("Fade")}</ToggleGroupItem>
</ToggleGroup>
<FieldDescription>
{t("เลือกเลเยอร์แล้วลากตรงบริเวณตัวอย่างเพื่อเลื่อนตำแหน่ง")}</FieldDescription>
</Field>
</CardContent>
</Card>
<Card className="order-4 lg:col-span-2 lg:col-start-1 lg:row-start-2">
<CardHeader>
<CardTitle>{t("สีธีม")}</CardTitle>
<CardDescription>
{t("สีขอบบนและล่างจะตรวจจากภาพตัวละครโดยอัตโนมัติเมื่ออัปโหลด และแก้ไขเองได้")}</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<ColorField
id="overview-top-color-start"
label={t("สีขอบบน เริ่มต้น")}
value={topColorStart}
onChange={setTopColorStart}
/>
<ColorField
id="overview-top-color-end"
label={t("สีขอบบน สิ้นสุด")}
value={topColorEnd}
onChange={setTopColorEnd}
/>
<ColorField
id="overview-bottom-color-start"
label={t("สีขอบล่าง เริ่มต้น")}
value={bottomColorStart}
onChange={setBottomColorStart}
/>
<ColorField
id="overview-bottom-color-end"
label={t("สีขอบล่าง สิ้นสุด")}
value={bottomColorEnd}
onChange={setBottomColorEnd}
/>
<ColorField
id="overview-element-color-start"
label={t("สีไล่ระดับธาตุ เริ่มต้น")}
value={elementColorStart}
onChange={setElementColorStart}
/>
<ColorField
id="overview-element-color-end"
label={t("สีไล่ระดับธาตุ สิ้นสุด")}
value={elementColorEnd}
onChange={setElementColorEnd}
/>
<Field>
<FieldLabel htmlFor="overview-element-gradient-angle">
{t("มุมสีไล่ระดับธาตุ")}{" "}{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
ref={captureRef}
aria-hidden="true"
className="pointer-events-none fixed top-0 -left-[10000px] order-5 size-[1000px]"
>
<LocaleBoundary locale={exportLocale ?? locale}>
<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}
disclaimerFontSize={disclaimerFontSize}
/>
</LocaleBoundary>
</div>
<Dialog
open={Boolean(pendingCrop)}
onOpenChange={(open) => {
if (!open) setPendingCrop(null);
}}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("ครอปภาพเป็น 1:1")}</DialogTitle>
<DialogDescription>
{t("เลื่อนตำแหน่งภาพให้ส่วนที่ต้องการอยู่ภายในกรอบสี่เหลี่ยม")}</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>
{t("ลากภาพหรือใช้ปุ่มลูกศรเพื่อเลือกพื้นที่ครอปสี่เหลี่ยม")}</CropperDescription>
<CropperImage />
<CropperCropArea />
</Cropper>
<div className="flex justify-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => rotateArtwork(-90)}
>
<RotateCcwIcon data-icon="inline-start" />
{t("หมุนซ้าย")}</Button>
<Button
type="button"
variant="outline"
onClick={() => rotateArtwork(90)}
>
<RotateCwIcon data-icon="inline-start" />
{t("หมุนขวา")}</Button>
</div>
<Field>
<FieldLabel htmlFor="crop-zoom">{t("ซูม")}</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)}>
{t("ยกเลิก")}</Button>
<Button disabled={!cropArea} onClick={applyCrop}>
<CropIcon data-icon="inline-start" />
{t("ใช้ภาพที่ครอป")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={nameCardCropOpen} onOpenChange={setNameCardCropOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("ครอปพื้นหลัง Namecard")}</DialogTitle>
<DialogDescription>
{t("ลากและซูม 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>
{t("ลากภาพหรือใช้ปุ่มลูกศรเพื่อเลือกพื้นหลังสี่เหลี่ยม")}</CropperDescription>
<CropperImage />
<CropperCropArea />
</Cropper>
<Field>
<FieldLabel htmlFor="namecard-zoom">{t("ซูม")}</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)}
>
{t("ยกเลิก")}</Button>
<Button
disabled={!nameCardDraftCrop}
onClick={() => {
if (!nameCardDraftCrop) return;
setNameCardCrop(nameCardDraftCrop);
setNameCardCropOpen(false);
}}
>
<CropIcon data-icon="inline-start" />
{t("ใช้ตำแหน่งนี้")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}