1636 lines
56 KiB
TypeScript
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>
|
|
);
|
|
}
|