"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 (
{children}
);
}
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;
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(null);
const posterRef = useRef(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 (
{composition.backgroundUrl ? (
) : character?.nameCardImageUrl ? (
nameCardCrop && nameCardSize ? (
) : (
)
) : null}
{artworkUrl ? (
{[...blendLayers].reverse().map((layer) => (
))}
) : (
)}
{weaponIcon ? (
) : null}
{role}
{character ? (
{character.name.toLocaleUpperCase("en")}
{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 (
);
}
return (
);
})}
{DISCLAIMER}
) : null}
);
}
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 (
{label}
{invalid ? กรุณาใส่สีรูปแบบ #RRGGBB : null}
);
}
function SortableBlendLayerRow({
layer,
onUpdate,
onRemove,
}: {
layer: BlendLayer;
onUpdate: (patch: Partial>) => void;
onRemove: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: layer.id });
const selectedMode = blendModeItems.find((mode) => mode.value === layer.mode);
return (
{
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"))
}
>
ไม่พบ Blend mode
{(item) => (
{item.label}
)}
ความเข้ม {layer.amount}%
onUpdate({
amount: typeof value === "number" ? value : value[0] ?? 0,
})
}
/>
);
}
export function OverviewImageGenerator({
characters,
elementIconUrls,
}: {
characters: Character[];
elementIconUrls: Record;
}) {
const captureRef = useRef(null);
const [character, setCharacter] = useState(null);
const [artworkUrl, setArtworkUrl] = useState(null);
const [artworkError, setArtworkError] = useState(null);
const [pendingCrop, setPendingCrop] = useState(null);
const [cropArea, setCropArea] = useState(null);
const [cropZoom, setCropZoom] = useState(1);
const [nameCardCrop, setNameCardCrop] = useState(null);
const [nameCardDraftCrop, setNameCardDraftCrop] = useState(
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("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([
{ 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(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({
x: 0,
y: 0,
});
const [fadeOffset, setFadeOffset] = useState(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>) {
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,
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((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((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((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((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((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 (
ตัวอย่าง
ภาพดาวน์โหลดจะใช้เลย์เอาต์เดียวกับตัวอย่าง
ตำแหน่ง Fade แนวตั้ง {fadeOffset.y}%
setFadeOffset((current) => ({
...current,
y: typeof value === "number" ? value : value[0] ?? 2,
}))
}
/>
ลากในตัวอย่างเพื่อเลื่อนตัวละคร และใช้แถบนี้เพื่อเลื่อน Fade
สีธีม
สีขอบบนและล่างจะตรวจจากภาพตัวละครโดยอัตโนมัติเมื่ออัปโหลด และแก้ไขเองได้
มุมสีไล่ระดับธาตุ {elementGradientAngle}°
setElementGradientAngle(
typeof value === "number" ? value : value[0] ?? 0,
)
}
/>
);
}