1639 lines
54 KiB
TypeScript
1639 lines
54 KiB
TypeScript
"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 { ClearableInput as Input } from "@/components/admin/ui/clearable-input";
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "@/components/ui/popover";
|
||
import {
|
||
ColorPicker,
|
||
ColorPickerEyeDropper,
|
||
ColorPickerHue,
|
||
ColorPickerSelection,
|
||
} from "@/components/ui/color-picker";
|
||
import { Slider } from "@/components/ui/slider";
|
||
import { Spinner } from "@/components/ui/spinner";
|
||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||
import { elementIconFilter } from "@/lib/catalog/element";
|
||
import {
|
||
accentGradient,
|
||
detectThemeColorsFromPixels,
|
||
elementGlowColor,
|
||
elementGradientColors,
|
||
elementOrder,
|
||
normalizeHex,
|
||
overviewFileName,
|
||
overviewRoles,
|
||
overviewBlendModes,
|
||
type OverviewBlendMode,
|
||
weaponTypeIconPath,
|
||
type OverviewRole,
|
||
} from "@/lib/overview-image";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
type Character = {
|
||
key: string;
|
||
name: string;
|
||
aliases: string[];
|
||
element: string | null;
|
||
rarity: number | null;
|
||
weaponType: string | null;
|
||
imageUrl: string;
|
||
nameCardImageUrl: string | null;
|
||
};
|
||
|
||
type PendingCrop = {
|
||
url: string;
|
||
target: "artwork" | "background";
|
||
};
|
||
|
||
type Composition = {
|
||
backgroundUrl: string | null;
|
||
fadeColor: string;
|
||
fadeTint: number;
|
||
auraUpOffset: number;
|
||
characterScale: number;
|
||
characterOffset: Position;
|
||
fadeOffset: Position;
|
||
};
|
||
|
||
type BlendLayer = {
|
||
id: number;
|
||
mode: OverviewBlendMode;
|
||
amount: number;
|
||
};
|
||
|
||
type CropArea = { x: number; y: number; width: number; height: number };
|
||
type Position = { x: number; y: number };
|
||
type PosterLayer = "character" | "fade";
|
||
|
||
const blendModeItems: Array<{ value: OverviewBlendMode; label: string }> = [
|
||
...overviewBlendModes,
|
||
];
|
||
|
||
const DISCLAIMER = "ไม่สามารถเอาไปเปรียบเทียบกับการคำนวณของ TC ท่านอื่นๆได้\nเพราะแต่ละคนใช้สแตทในการคำนวณที่ไม่เหมือนกัน\nและตารางที่เห็นอาจมีการเปลี่ยนแปลงได้ในอนาคต";
|
||
const DEFAULT_FADE_OFFSET: Position = { x: 0, y: 2 };
|
||
|
||
function DraggablePosterLayer({
|
||
id,
|
||
offset,
|
||
scale = 1,
|
||
interactive,
|
||
className,
|
||
style,
|
||
children,
|
||
}: {
|
||
id: PosterLayer;
|
||
offset: Position;
|
||
scale?: number;
|
||
interactive: boolean;
|
||
className?: string;
|
||
style?: CSSProperties;
|
||
children: React.ReactNode;
|
||
}) {
|
||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||
id,
|
||
disabled: !interactive,
|
||
});
|
||
const temporaryTransform = transform
|
||
? ` translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||
: "";
|
||
const dragProps = interactive ? { ...attributes, ...listeners } : {};
|
||
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
className={className}
|
||
style={{
|
||
...style,
|
||
transform: `translate(${offset.x}%, ${offset.y}%)${temporaryTransform} scale(${scale})`,
|
||
}}
|
||
{...dragProps}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function OverviewPoster({
|
||
artworkUrl,
|
||
character,
|
||
role,
|
||
topColors,
|
||
bottomColors,
|
||
elementColors,
|
||
elementGradientAngle,
|
||
blendLayers,
|
||
nameCardCrop,
|
||
nameCardSize,
|
||
elementIconUrls,
|
||
composition,
|
||
interactive = false,
|
||
activeLayer,
|
||
onLayerMove,
|
||
}: {
|
||
artworkUrl: string | null;
|
||
character: Character | null;
|
||
role: string;
|
||
topColors: [string, string];
|
||
bottomColors: [string, string];
|
||
elementColors: [string, string];
|
||
elementGradientAngle: number;
|
||
blendLayers: BlendLayer[];
|
||
nameCardCrop: CropArea | null;
|
||
nameCardSize: { width: number; height: number } | null;
|
||
elementIconUrls: Record<string, string | null>;
|
||
composition: Composition;
|
||
interactive?: boolean;
|
||
activeLayer?: PosterLayer;
|
||
onLayerMove?: (layer: PosterLayer, delta: Position) => void;
|
||
}) {
|
||
const weaponIcon = weaponTypeIconPath(character?.weaponType ?? null);
|
||
const z = {
|
||
character: 1,
|
||
fade: 2,
|
||
aura: 3,
|
||
badge: 4,
|
||
text: 5,
|
||
} as const;
|
||
const titleRef = useRef<HTMLParagraphElement>(null);
|
||
const posterRef = useRef<HTMLDivElement>(null);
|
||
const posterSensors = useSensors(
|
||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||
);
|
||
|
||
function handlePosterDragEnd({ active, delta }: DragEndEvent) {
|
||
if (!interactive || !onLayerMove) return;
|
||
const size = posterRef.current?.getBoundingClientRect().width;
|
||
if (!size || (active.id !== "character" && active.id !== "fade")) return;
|
||
onLayerMove(active.id, {
|
||
x: (delta.x / size) * 100,
|
||
y: (delta.y / size) * 100,
|
||
});
|
||
}
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
const fit = () => {
|
||
if (cancelled || !titleRef.current) return;
|
||
const title = titleRef.current;
|
||
const width = title.parentElement!.getBoundingClientRect().width;
|
||
title.style.fontSize = `${width / 10}px`;
|
||
if (title.scrollWidth > width) {
|
||
title.style.fontSize = `${((width / 10) * width) / title.scrollWidth}px`;
|
||
}
|
||
};
|
||
const observer = new ResizeObserver(fit);
|
||
if (titleRef.current?.parentElement)
|
||
observer.observe(titleRef.current.parentElement);
|
||
void document.fonts.ready.then(fit);
|
||
return () => {
|
||
cancelled = true;
|
||
observer.disconnect();
|
||
};
|
||
}, [character?.name]);
|
||
|
||
return (
|
||
<DndContext sensors={posterSensors} onDragEnd={handlePosterDragEnd}>
|
||
<div
|
||
ref={posterRef}
|
||
className="relative isolate aspect-square size-full overflow-hidden bg-black text-white @container"
|
||
style={{ fontFamily: '"Genshin Overview", sans-serif' }}
|
||
>
|
||
{composition.backgroundUrl ? (
|
||
<Image
|
||
src={composition.backgroundUrl}
|
||
alt=""
|
||
fill
|
||
unoptimized
|
||
loading="eager"
|
||
className="object-cover"
|
||
/>
|
||
) : character?.nameCardImageUrl ? (
|
||
nameCardCrop && nameCardSize ? (
|
||
<Image
|
||
src={character.nameCardImageUrl}
|
||
alt=""
|
||
width={nameCardSize.width}
|
||
height={nameCardSize.height}
|
||
loading="eager"
|
||
sizes="1024px"
|
||
className="absolute max-w-none"
|
||
style={{
|
||
width: `${(nameCardSize.width / nameCardCrop.width) * 100}%`,
|
||
height: `${(nameCardSize.height / nameCardCrop.height) * 100}%`,
|
||
left: `${(-nameCardCrop.x / nameCardCrop.width) * 100}%`,
|
||
top: `${(-nameCardCrop.y / nameCardCrop.height) * 100}%`,
|
||
opacity: 0.7,
|
||
}}
|
||
/>
|
||
) : (
|
||
<Image
|
||
src={character.nameCardImageUrl}
|
||
alt=""
|
||
fill
|
||
loading="eager"
|
||
sizes="1024px"
|
||
className="object-cover opacity-70"
|
||
/>
|
||
)
|
||
) : null}
|
||
{artworkUrl ? (
|
||
<DraggablePosterLayer
|
||
id="character"
|
||
offset={composition.characterOffset}
|
||
scale={composition.characterScale}
|
||
interactive={interactive && activeLayer === "character"}
|
||
className={cn(
|
||
"absolute inset-0 cursor-grab active:cursor-grabbing",
|
||
interactive && "touch-none",
|
||
)}
|
||
style={{ zIndex: z.character }}
|
||
>
|
||
<Image
|
||
src={artworkUrl}
|
||
alt=""
|
||
fill
|
||
unoptimized
|
||
loading="eager"
|
||
sizes="1024px"
|
||
className="object-contain"
|
||
/>
|
||
{[...blendLayers].reverse().map((layer) => (
|
||
<Image
|
||
key={layer.id}
|
||
src={artworkUrl}
|
||
alt=""
|
||
fill
|
||
unoptimized
|
||
loading="eager"
|
||
sizes="1024px"
|
||
className="object-contain"
|
||
style={{
|
||
mixBlendMode: layer.mode,
|
||
opacity: layer.amount / 100,
|
||
}}
|
||
/>
|
||
))}
|
||
</DraggablePosterLayer>
|
||
) : (
|
||
<div className="absolute inset-0 grid place-items-center bg-muted text-muted-foreground">
|
||
<ImageIcon aria-hidden="true" className="size-[12cqw]" />
|
||
</div>
|
||
)}
|
||
|
||
<DraggablePosterLayer
|
||
id="fade"
|
||
offset={composition.fadeOffset}
|
||
interactive={interactive && activeLayer === "fade"}
|
||
className={cn(
|
||
"absolute inset-x-0 top-[-3cqw] bottom-0",
|
||
activeLayer === "fade"
|
||
? "touch-none cursor-grab active:cursor-grabbing"
|
||
: "pointer-events-none",
|
||
)}
|
||
style={{ zIndex: z.fade }}
|
||
>
|
||
<Image
|
||
src="/img/fade.png"
|
||
alt=""
|
||
fill
|
||
unoptimized
|
||
loading="eager"
|
||
sizes="1024px"
|
||
className="object-fill"
|
||
/>
|
||
<div
|
||
className="absolute inset-0"
|
||
style={{
|
||
backgroundColor: normalizeHex(composition.fadeColor) ?? "#0E0200",
|
||
opacity: composition.fadeTint / 100,
|
||
mask: "url('/img/fade.png') center / 100% 100% no-repeat",
|
||
maskMode: "alpha",
|
||
WebkitMask: "url('/img/fade.png') center / 100% 100% no-repeat",
|
||
}}
|
||
/>
|
||
</DraggablePosterLayer>
|
||
|
||
<div
|
||
className="absolute inset-0 pointer-events-none"
|
||
style={{ zIndex: z.aura }}
|
||
>
|
||
<div
|
||
className="absolute inset-0"
|
||
style={{
|
||
backgroundImage: accentGradient(...topColors),
|
||
transform: `translateY(-${composition.auraUpOffset}%)`,
|
||
maskImage: "url('/img/aura-up.png')",
|
||
maskMode: "alpha",
|
||
maskPosition: "center",
|
||
maskRepeat: "no-repeat",
|
||
maskSize: "100% 100%",
|
||
WebkitMaskImage: "url('/img/aura-up.png')",
|
||
WebkitMaskPosition: "center",
|
||
WebkitMaskRepeat: "no-repeat",
|
||
WebkitMaskSize: "100% 100%",
|
||
}}
|
||
/>
|
||
<div
|
||
className="absolute inset-0"
|
||
style={{
|
||
backgroundImage: accentGradient(...bottomColors),
|
||
maskImage: "url('/img/aura-down.png')",
|
||
maskMode: "alpha",
|
||
maskPosition: "center",
|
||
maskRepeat: "no-repeat",
|
||
maskSize: "100% 100%",
|
||
WebkitMaskImage: "url('/img/aura-down.png')",
|
||
WebkitMaskPosition: "center",
|
||
WebkitMaskRepeat: "no-repeat",
|
||
WebkitMaskSize: "100% 100%",
|
||
}}
|
||
/>
|
||
|
||
<div
|
||
className="absolute inset-x-0 top-0 h-[1.35cqw]"
|
||
style={{ backgroundImage: accentGradient(...topColors) }}
|
||
/>
|
||
<div
|
||
className="absolute inset-x-0 bottom-0 h-[1.35cqw]"
|
||
style={{ backgroundImage: accentGradient(...bottomColors) }}
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
className="absolute inset-0 pointer-events-none"
|
||
style={{ zIndex: z.badge }}
|
||
>
|
||
<Image
|
||
src="/img/badge.png"
|
||
alt=""
|
||
fill
|
||
unoptimized
|
||
loading="eager"
|
||
sizes="1024px"
|
||
className="object-fill"
|
||
/>
|
||
<div
|
||
className="absolute top-[4.43cqw] left-0 flex h-[6.2cqw] items-center gap-[0.45cqw] text-[4.4cqw] leading-none font-semibold"
|
||
style={{ fontFamily: '"Kanit Overview", sans-serif' }}
|
||
>
|
||
{weaponIcon ? (
|
||
<Image
|
||
src={weaponIcon}
|
||
alt=""
|
||
width={128}
|
||
height={128}
|
||
loading="eager"
|
||
className="ml-[0.3cqw] size-[5.65cqw] object-contain brightness-0 invert"
|
||
/>
|
||
) : null}
|
||
<span>{role}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{character ? (
|
||
<div
|
||
className="absolute inset-x-[5cqw] top-[71cqw] flex flex-col items-center text-center"
|
||
style={{ zIndex: z.text }}
|
||
>
|
||
<p
|
||
ref={titleRef}
|
||
className="w-fit max-w-full whitespace-nowrap leading-none font-normal tracking-[0.04em]"
|
||
style={{ fontSize: "9cqw" }}
|
||
>
|
||
{character.name.toLocaleUpperCase("en")}
|
||
</p>
|
||
<div className="mt-[0.8cqw] flex items-center justify-center gap-[0.65cqw]">
|
||
{elementOrder.map((element) => {
|
||
const url = elementIconUrls[element];
|
||
const active = element === character.element;
|
||
if (!url) return null;
|
||
if (active) {
|
||
const { props: maskImageProps } = getImageProps({
|
||
src: url,
|
||
alt: "",
|
||
width: 72,
|
||
height: 72,
|
||
});
|
||
return (
|
||
<span
|
||
key={element}
|
||
className="relative size-[3.5cqw] shrink-0"
|
||
style={{
|
||
filter: `drop-shadow(0 0 0.43cqw ${elementGlowColor(element)}80)`,
|
||
}}
|
||
>
|
||
<Image
|
||
src={url}
|
||
alt=""
|
||
fill
|
||
loading="eager"
|
||
sizes="5cqw"
|
||
className="object-contain"
|
||
style={{ filter: elementIconFilter(element) }}
|
||
/>
|
||
<span
|
||
className="absolute inset-0"
|
||
style={{
|
||
backgroundImage: `linear-gradient(${elementGradientAngle}deg, ${normalizeHex(elementColors[0]) ?? "#FFFFFF"}, ${normalizeHex(elementColors[1]) ?? "#FFFFFF"})`,
|
||
maskImage: `url(${JSON.stringify(maskImageProps.src)})`,
|
||
maskMode: "alpha",
|
||
maskPosition: "center",
|
||
maskRepeat: "no-repeat",
|
||
maskSize: "contain",
|
||
WebkitMaskImage: `url(${JSON.stringify(maskImageProps.src)})`,
|
||
WebkitMaskPosition: "center",
|
||
WebkitMaskRepeat: "no-repeat",
|
||
WebkitMaskSize: "contain",
|
||
}}
|
||
/>
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<Image
|
||
key={element}
|
||
src={url}
|
||
alt=""
|
||
width={72}
|
||
height={72}
|
||
loading="eager"
|
||
sizes="5cqw"
|
||
className="size-[3.5cqw] object-contain brightness-0 invert opacity-30"
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
<p
|
||
className="mt-[1.5cqw] w-max whitespace-pre text-center text-[2.03167cqw] leading-[1.15]"
|
||
>
|
||
{DISCLAIMER}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</DndContext>
|
||
);
|
||
}
|
||
|
||
function ColorField({
|
||
id,
|
||
label,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
id: string;
|
||
label: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
}) {
|
||
const invalid = !normalizeHex(value);
|
||
const handlePickerChange = useCallback(
|
||
(color: unknown) => {
|
||
if (!Array.isArray(color)) return;
|
||
const hex = color
|
||
.slice(0, 3)
|
||
.map((channel) => Math.round(channel).toString(16).padStart(2, "0"))
|
||
.join("");
|
||
onChange(`#${hex.toUpperCase()}`);
|
||
},
|
||
[onChange],
|
||
);
|
||
|
||
return (
|
||
<Field data-invalid={invalid}>
|
||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||
<div className="flex gap-2">
|
||
<Popover>
|
||
<PopoverTrigger
|
||
render={
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="icon"
|
||
aria-label={`เลือก${label}`}
|
||
>
|
||
<span
|
||
className="size-5 rounded-sm border"
|
||
style={{ backgroundColor: normalizeHex(value) ?? "#000000" }}
|
||
/>
|
||
</Button>
|
||
}
|
||
/>
|
||
<PopoverContent align="start" className="w-64">
|
||
<ColorPicker
|
||
value={normalizeHex(value) ?? "#000000"}
|
||
onChange={handlePickerChange}
|
||
className="h-auto"
|
||
>
|
||
<ColorPickerSelection className="h-32" />
|
||
<ColorPickerHue />
|
||
<ColorPickerEyeDropper aria-label="ดูดสีจากหน้าจอ" />
|
||
</ColorPicker>
|
||
</PopoverContent>
|
||
</Popover>
|
||
<Input
|
||
id={id}
|
||
value={value}
|
||
maxLength={7}
|
||
aria-invalid={invalid}
|
||
placeholder="#E9A51A"
|
||
onChange={(event) => onChange(event.target.value)}
|
||
/>
|
||
</div>
|
||
{invalid ? <FieldError>กรุณาใส่สีรูปแบบ #RRGGBB</FieldError> : null}
|
||
</Field>
|
||
);
|
||
}
|
||
|
||
function SortableBlendLayerRow({
|
||
layer,
|
||
onUpdate,
|
||
onRemove,
|
||
}: {
|
||
layer: BlendLayer;
|
||
onUpdate: (patch: Partial<Omit<BlendLayer, "id">>) => void;
|
||
onRemove: () => void;
|
||
}) {
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging,
|
||
} = useSortable({ id: layer.id });
|
||
const selectedMode = blendModeItems.find((mode) => mode.value === layer.mode);
|
||
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
className={cn(
|
||
"flex items-start gap-2 rounded-lg border bg-background p-3",
|
||
isDragging && "relative z-10 opacity-60 shadow-md",
|
||
)}
|
||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||
>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="touch-none cursor-grab active:cursor-grabbing"
|
||
aria-label="ลากเพื่อจัดลำดับ Blend mode"
|
||
{...attributes}
|
||
{...listeners}
|
||
>
|
||
<GripVerticalIcon data-icon="inline-start" />
|
||
</Button>
|
||
<div className="min-w-0 flex-1">
|
||
<Combobox
|
||
items={blendModeItems}
|
||
value={selectedMode}
|
||
onValueChange={(value) => {
|
||
if (value) onUpdate({ mode: value.value });
|
||
}}
|
||
itemToStringLabel={(item) => item.label}
|
||
itemToStringValue={(item) => item.value}
|
||
filter={(item, query) =>
|
||
`${item.label} ${item.value}`
|
||
.toLocaleLowerCase("en")
|
||
.includes(query.trim().toLocaleLowerCase("en"))
|
||
}
|
||
>
|
||
<ComboboxInput className="w-full" placeholder="เลือก Blend mode…" />
|
||
<ComboboxContent>
|
||
<ComboboxEmpty>ไม่พบ Blend mode</ComboboxEmpty>
|
||
<ComboboxList>
|
||
{(item) => (
|
||
<ComboboxItem key={item.value} value={item}>
|
||
{item.label}
|
||
</ComboboxItem>
|
||
)}
|
||
</ComboboxList>
|
||
</ComboboxContent>
|
||
</Combobox>
|
||
<Field className="mt-3">
|
||
<FieldLabel htmlFor={`overview-blend-amount-${layer.id}`}>
|
||
ความเข้ม {layer.amount}%
|
||
</FieldLabel>
|
||
<Slider
|
||
id={`overview-blend-amount-${layer.id}`}
|
||
min={0}
|
||
max={100}
|
||
step={5}
|
||
value={[layer.amount]}
|
||
onValueChange={(value) =>
|
||
onUpdate({
|
||
amount: typeof value === "number" ? value : value[0] ?? 0,
|
||
})
|
||
}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label="ลบ Blend mode"
|
||
onClick={onRemove}
|
||
>
|
||
<Trash2Icon data-icon="inline-start" />
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function OverviewImageGenerator({
|
||
characters,
|
||
elementIconUrls,
|
||
}: {
|
||
characters: Character[];
|
||
elementIconUrls: Record<string, string | null>;
|
||
}) {
|
||
const captureRef = useRef<HTMLDivElement>(null);
|
||
const [character, setCharacter] = useState<Character | null>(null);
|
||
const [artworkUrl, setArtworkUrl] = useState<string | null>(null);
|
||
const [artworkError, setArtworkError] = useState<string | null>(null);
|
||
const [pendingCrop, setPendingCrop] = useState<PendingCrop | null>(null);
|
||
const [cropArea, setCropArea] = useState<CropArea | null>(null);
|
||
const [cropZoom, setCropZoom] = useState(1);
|
||
const [nameCardCrop, setNameCardCrop] = useState<CropArea | null>(null);
|
||
const [nameCardDraftCrop, setNameCardDraftCrop] = useState<CropArea | null>(
|
||
null,
|
||
);
|
||
const [nameCardSize, setNameCardSize] = useState<{
|
||
width: number;
|
||
height: number;
|
||
} | null>(null);
|
||
const [nameCardCropOpen, setNameCardCropOpen] = useState(false);
|
||
const [nameCardZoom, setNameCardZoom] = useState(1);
|
||
const [role, setRole] = useState<OverviewRole>("Main DPS");
|
||
const [customRoleText, setCustomRoleText] = useState("");
|
||
const [topColorStart, setTopColorStart] = useState("#E9A51A");
|
||
const [topColorEnd, setTopColorEnd] = useState("#FFD84A");
|
||
const [bottomColorStart, setBottomColorStart] = useState("#E9A51A");
|
||
const [bottomColorEnd, setBottomColorEnd] = useState("#FFD84A");
|
||
const [elementColorStart, setElementColorStart] = useState("#FFFFFF");
|
||
const [elementColorEnd, setElementColorEnd] = useState("#FFFFFF");
|
||
const [elementGradientAngle, setElementGradientAngle] = useState(101);
|
||
const [blendLayers, setBlendLayers] = useState<BlendLayer[]>([
|
||
{ id: 1, mode: "color-burn", amount: 100 },
|
||
]);
|
||
const nextBlendLayerId = useRef(2);
|
||
const blendLayerSensors = useSensors(
|
||
useSensor(PointerSensor),
|
||
useSensor(KeyboardSensor, {
|
||
coordinateGetter: sortableKeyboardCoordinates,
|
||
}),
|
||
);
|
||
const [exporting, setExporting] = useState(false);
|
||
const [backgroundUrl, setBackgroundUrl] = useState<string | null>(null);
|
||
const [fadeColor, setFadeColor] = useState("#0E0200");
|
||
const [fadeTint, setFadeTint] = useState(0);
|
||
const [auraUpOffset, setAuraUpOffset] = useState(4);
|
||
const [characterScale, setCharacterScale] = useState(100);
|
||
const [characterOffset, setCharacterOffset] = useState<Position>({
|
||
x: 0,
|
||
y: 0,
|
||
});
|
||
const [fadeOffset, setFadeOffset] = useState<Position>(DEFAULT_FADE_OFFSET);
|
||
const composition: Composition = {
|
||
backgroundUrl,
|
||
fadeColor,
|
||
fadeTint,
|
||
auraUpOffset,
|
||
characterScale: characterScale / 100,
|
||
characterOffset,
|
||
fadeOffset,
|
||
};
|
||
const displayRole =
|
||
role === "Custom" ? customRoleText.trim() || "Custom" : role;
|
||
|
||
function detectArtworkTheme(
|
||
source: CanvasImageSource,
|
||
width: number,
|
||
height: number,
|
||
) {
|
||
const maxDimension = 64;
|
||
const scale = Math.min(1, maxDimension / Math.max(width, height));
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = Math.max(1, Math.round(width * scale));
|
||
canvas.height = Math.max(1, Math.round(height * scale));
|
||
const context = canvas.getContext("2d");
|
||
if (!context) return;
|
||
context.drawImage(source, 0, 0, width, height, 0, 0, canvas.width, canvas.height);
|
||
const theme = detectThemeColorsFromPixels(
|
||
context.getImageData(0, 0, canvas.width, canvas.height).data,
|
||
canvas.width,
|
||
canvas.height,
|
||
);
|
||
setTopColorStart(theme.top[0]);
|
||
setTopColorEnd(theme.top[1]);
|
||
setBottomColorStart(theme.bottom[0]);
|
||
setBottomColorEnd(theme.bottom[1]);
|
||
}
|
||
|
||
function addBlendLayer() {
|
||
setBlendLayers((current) => [
|
||
...current,
|
||
{
|
||
id: nextBlendLayerId.current++,
|
||
mode: "normal",
|
||
amount: 100,
|
||
},
|
||
]);
|
||
}
|
||
|
||
function updateBlendLayer(id: number, patch: Partial<Omit<BlendLayer, "id">>) {
|
||
setBlendLayers((current) =>
|
||
current.map((layer) => (layer.id === id ? { ...layer, ...patch } : layer)),
|
||
);
|
||
}
|
||
|
||
function removeBlendLayer(id: number) {
|
||
setBlendLayers((current) => current.filter((layer) => layer.id !== id));
|
||
}
|
||
|
||
function handleBlendLayerDragEnd({ active, over }: DragEndEvent) {
|
||
if (!over || active.id === over.id) return;
|
||
setBlendLayers((current) => {
|
||
const oldIndex = current.findIndex(
|
||
(layer) => String(layer.id) === String(active.id),
|
||
);
|
||
const newIndex = current.findIndex(
|
||
(layer) => String(layer.id) === String(over.id),
|
||
);
|
||
if (oldIndex === -1 || newIndex === -1) return current;
|
||
return arrayMove(current, oldIndex, newIndex);
|
||
});
|
||
}
|
||
|
||
function movePosterLayer(layer: PosterLayer, delta: Position) {
|
||
const update = (current: Position): Position => ({
|
||
x: Number((current.x + delta.x).toFixed(2)),
|
||
y: Number((current.y + delta.y).toFixed(2)),
|
||
});
|
||
if (layer === "character") setCharacterOffset(update);
|
||
else setFadeOffset(update);
|
||
}
|
||
|
||
function resetPosterPositions() {
|
||
setCharacterOffset({ x: 0, y: 0 });
|
||
setFadeOffset(DEFAULT_FADE_OFFSET);
|
||
}
|
||
|
||
useEffect(() => {
|
||
const url = character?.nameCardImageUrl;
|
||
if (!url) return;
|
||
|
||
let cancelled = false;
|
||
const image = new window.Image();
|
||
image.onload = () => {
|
||
if (cancelled) return;
|
||
const size = Math.min(image.naturalWidth, image.naturalHeight);
|
||
setNameCardSize({
|
||
width: image.naturalWidth,
|
||
height: image.naturalHeight,
|
||
});
|
||
setNameCardCrop({
|
||
x: (image.naturalWidth - size) / 2,
|
||
y: (image.naturalHeight - size) / 2,
|
||
width: size,
|
||
height: size,
|
||
});
|
||
};
|
||
image.onerror = () => {
|
||
if (!cancelled) toast.error("ไม่สามารถโหลดภาพ Namecard ได้");
|
||
};
|
||
image.src = url;
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [character]);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
if (pendingCrop) URL.revokeObjectURL(pendingCrop.url);
|
||
},
|
||
[pendingCrop],
|
||
);
|
||
|
||
async function selectArtwork(
|
||
event: ChangeEvent<HTMLInputElement>,
|
||
target: "artwork" | "background" = "artwork",
|
||
) {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = "";
|
||
if (!file) return;
|
||
|
||
const url = URL.createObjectURL(file);
|
||
try {
|
||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||
const image = new window.Image();
|
||
image.onload = () => resolve(image);
|
||
image.onerror = reject;
|
||
image.src = url;
|
||
});
|
||
if (target === "artwork") {
|
||
detectArtworkTheme(image, image.naturalWidth, image.naturalHeight);
|
||
setCharacterScale(100);
|
||
setArtworkUrl(await new Promise<string>((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result as string);
|
||
reader.onerror = reject;
|
||
reader.readAsDataURL(file);
|
||
}));
|
||
URL.revokeObjectURL(url);
|
||
setArtworkError(null);
|
||
return;
|
||
}
|
||
setArtworkError(null);
|
||
setCropArea(null);
|
||
setCropZoom(1);
|
||
setPendingCrop({ url, target });
|
||
} catch {
|
||
URL.revokeObjectURL(url);
|
||
setArtworkError("ไม่สามารถอ่านไฟล์ภาพนี้ได้");
|
||
}
|
||
}
|
||
|
||
async function applyCrop() {
|
||
if (!pendingCrop || !cropArea) return;
|
||
try {
|
||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||
const candidate = new window.Image();
|
||
candidate.onload = () => resolve(candidate);
|
||
candidate.onerror = reject;
|
||
candidate.src = pendingCrop.url;
|
||
});
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = Math.round(cropArea.width);
|
||
canvas.height = Math.round(cropArea.height);
|
||
const context = canvas.getContext("2d");
|
||
if (!context) throw new Error("Canvas is unavailable");
|
||
context.imageSmoothingEnabled = true;
|
||
context.imageSmoothingQuality = "high";
|
||
context.drawImage(
|
||
image,
|
||
cropArea.x,
|
||
cropArea.y,
|
||
cropArea.width,
|
||
cropArea.height,
|
||
0,
|
||
0,
|
||
canvas.width,
|
||
canvas.height,
|
||
);
|
||
if (pendingCrop.target === "artwork") {
|
||
detectArtworkTheme(canvas, canvas.width, canvas.height);
|
||
}
|
||
const croppedUrl = canvas.toDataURL("image/png");
|
||
if (pendingCrop.target === "background")
|
||
setBackgroundUrl(croppedUrl);
|
||
else setArtworkUrl(croppedUrl);
|
||
setPendingCrop(null);
|
||
setArtworkError(null);
|
||
} catch {
|
||
setArtworkError("ไม่สามารถครอปไฟล์ภาพนี้ได้");
|
||
setPendingCrop(null);
|
||
}
|
||
}
|
||
|
||
async function rotateArtwork(degrees: -90 | 90) {
|
||
if (!pendingCrop) return;
|
||
try {
|
||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||
const candidate = new window.Image();
|
||
candidate.onload = () => resolve(candidate);
|
||
candidate.onerror = reject;
|
||
candidate.src = pendingCrop.url;
|
||
});
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = image.naturalHeight;
|
||
canvas.height = image.naturalWidth;
|
||
const context = canvas.getContext("2d");
|
||
if (!context) throw new Error("Canvas is unavailable");
|
||
context.translate(canvas.width / 2, canvas.height / 2);
|
||
context.rotate((degrees * Math.PI) / 180);
|
||
context.drawImage(
|
||
image,
|
||
-image.naturalWidth / 2,
|
||
-image.naturalHeight / 2,
|
||
);
|
||
const blob = await new Promise<Blob>((resolve, reject) =>
|
||
canvas.toBlob(
|
||
(value) =>
|
||
value ? resolve(value) : reject(new Error("Rotation failed")),
|
||
"image/png",
|
||
),
|
||
);
|
||
setCropArea(null);
|
||
setCropZoom(1);
|
||
setPendingCrop({
|
||
url: URL.createObjectURL(blob),
|
||
target: pendingCrop.target,
|
||
});
|
||
} catch {
|
||
toast.error("หมุนภาพไม่สำเร็จ กรุณาลองอีกครั้ง");
|
||
}
|
||
}
|
||
|
||
async function download() {
|
||
const node = captureRef.current;
|
||
if (
|
||
!node ||
|
||
!character ||
|
||
!artworkUrl ||
|
||
!normalizeHex(topColorStart) ||
|
||
!normalizeHex(topColorEnd) ||
|
||
!normalizeHex(bottomColorStart) ||
|
||
!normalizeHex(bottomColorEnd) ||
|
||
!normalizeHex(elementColorStart) ||
|
||
!normalizeHex(elementColorEnd) ||
|
||
!normalizeHex(fadeColor)
|
||
)
|
||
return;
|
||
|
||
setExporting(true);
|
||
try {
|
||
const { domToBlob, waitUntilLoad } = await import("modern-screenshot");
|
||
await Promise.all([document.fonts.ready, waitUntilLoad(node)]);
|
||
const blob = await domToBlob(node, {
|
||
backgroundColor: "#000000",
|
||
type: "image/png",
|
||
width: 1000,
|
||
height: 1000,
|
||
scale: 3,
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = overviewFileName(character.key, displayRole);
|
||
document.body.append(link);
|
||
link.click();
|
||
link.remove();
|
||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||
toast.success("ดาวน์โหลดภาพ Overview แล้ว");
|
||
} catch {
|
||
toast.error("ส่งออกภาพไม่สำเร็จ กรุณาลองอีกครั้ง");
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
}
|
||
|
||
const ready = Boolean(
|
||
character &&
|
||
artworkUrl &&
|
||
normalizeHex(topColorStart) &&
|
||
normalizeHex(topColorEnd) &&
|
||
normalizeHex(bottomColorStart) &&
|
||
normalizeHex(bottomColorEnd) &&
|
||
normalizeHex(elementColorStart) &&
|
||
normalizeHex(elementColorEnd) &&
|
||
normalizeHex(fadeColor) &&
|
||
weaponTypeIconPath(character.weaponType),
|
||
);
|
||
|
||
return (
|
||
<div className="grid items-start gap-6 lg:grid-cols-[minmax(16rem,1fr)_minmax(16rem,1fr)_minmax(20rem,1.3fr)]">
|
||
<div className="contents">
|
||
<Card className="order-1 lg:col-start-1 lg:row-start-1">
|
||
<CardHeader>
|
||
<CardTitle>ตั้งค่าภาพ</CardTitle>
|
||
<CardDescription>ไฟล์ภาพจะใช้เฉพาะในเบราว์เซอร์นี้</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<FieldGroup>
|
||
<Field>
|
||
<FieldLabel>ตัวละคร</FieldLabel>
|
||
<CharacterSelect
|
||
items={characters}
|
||
value={character?.key ?? ""}
|
||
placeholder="ค้นหาตัวละคร…"
|
||
onValueChange={(key) => {
|
||
const value = characters.find((item) => item.key === key) ?? null;
|
||
if (value?.element !== character?.element) {
|
||
const [start, end] = elementGradientColors(value?.element);
|
||
setElementColorStart(start);
|
||
setElementColorEnd(end);
|
||
}
|
||
setCharacter(value);
|
||
setNameCardCrop(null);
|
||
setNameCardSize(null);
|
||
setNameCardCropOpen(false);
|
||
}}
|
||
emptyLabel="ไม่พบตัวละคร"
|
||
/>
|
||
<FieldDescription>
|
||
ชื่อ ธาตุ และประเภทอาวุธจะมาจาก Catalog
|
||
</FieldDescription>
|
||
</Field>
|
||
|
||
<Field>
|
||
<FieldLabel>พื้นหลัง Namecard</FieldLabel>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
disabled={!character?.nameCardImageUrl || !nameCardSize}
|
||
onClick={() => {
|
||
setNameCardDraftCrop(null);
|
||
setNameCardZoom(1);
|
||
setNameCardCropOpen(true);
|
||
}}
|
||
>
|
||
<CropIcon data-icon="inline-start" />
|
||
เลือกตำแหน่งพื้นหลัง
|
||
</Button>
|
||
<FieldDescription>
|
||
ใช้ Namecard ของตัวละครใต้ภาพโปร่งใส
|
||
</FieldDescription>
|
||
</Field>
|
||
|
||
<Field data-invalid={Boolean(artworkError)}>
|
||
<FieldLabel htmlFor="overview-artwork">ภาพตัวละคร 1:1</FieldLabel>
|
||
<Input
|
||
id="overview-artwork"
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
aria-invalid={Boolean(artworkError)}
|
||
onChange={(event) => selectArtwork(event)}
|
||
/>
|
||
<FieldDescription>
|
||
ใช้ภาพเต็มโดยไม่ครอป และสามารถลากเพื่อจัดตำแหน่งในตัวอย่างได้
|
||
</FieldDescription>
|
||
{artworkError ? <FieldError>{artworkError}</FieldError> : null}
|
||
</Field>
|
||
|
||
<Field data-disabled={!artworkUrl}>
|
||
<FieldLabel htmlFor="overview-artwork-zoom">
|
||
ซูมภาพตัวละคร {characterScale}%
|
||
</FieldLabel>
|
||
<Slider
|
||
id="overview-artwork-zoom"
|
||
min={25}
|
||
max={300}
|
||
step={1}
|
||
value={[characterScale]}
|
||
disabled={!artworkUrl}
|
||
onValueChange={(value) =>
|
||
setCharacterScale(
|
||
typeof value === "number" ? value : value[0] ?? 100,
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
|
||
<Field>
|
||
<FieldLabel htmlFor="overview-background">
|
||
อัปโหลดพื้นหลัง
|
||
</FieldLabel>
|
||
<Input
|
||
id="overview-background"
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
onChange={(event) => selectArtwork(event, "background")}
|
||
/>
|
||
{backgroundUrl ? (
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setBackgroundUrl(null)}
|
||
>
|
||
กลับไปใช้ Namecard
|
||
</Button>
|
||
) : null}
|
||
<FieldDescription>
|
||
ครอปและซูมพื้นหลังเป็นสี่เหลี่ยมก่อนใช้งาน
|
||
</FieldDescription>
|
||
</Field>
|
||
|
||
</FieldGroup>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="order-3 lg:col-start-3 lg:row-span-2 lg:row-start-1">
|
||
<CardHeader>
|
||
<CardTitle>ธีมและเอฟเฟกต์</CardTitle>
|
||
<CardDescription>
|
||
สีขอบบนและล่างจะตรวจจากภาพตัวละครโดยอัตโนมัติเมื่ออัปโหลด
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<FieldGroup>
|
||
<ColorField
|
||
id="overview-fade-color"
|
||
label="สี Fade"
|
||
value={fadeColor}
|
||
onChange={setFadeColor}
|
||
/>
|
||
<Field>
|
||
<FieldLabel htmlFor="overview-fade-tint">
|
||
ความเข้มสี Fade {fadeTint}%
|
||
</FieldLabel>
|
||
<Slider
|
||
id="overview-fade-tint"
|
||
min={0}
|
||
max={100}
|
||
step={1}
|
||
value={[fadeTint]}
|
||
onValueChange={(value) =>
|
||
setFadeTint(typeof value === "number" ? value : value[0] ?? 0)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field>
|
||
<FieldLabel htmlFor="overview-aura-up">
|
||
เลื่อน Aura ด้านบนขึ้น {auraUpOffset}%
|
||
</FieldLabel>
|
||
<Slider
|
||
id="overview-aura-up"
|
||
min={0}
|
||
max={7}
|
||
step={0.1}
|
||
value={[auraUpOffset]}
|
||
onValueChange={(value) =>
|
||
setAuraUpOffset(
|
||
typeof value === "number" ? value : value[0] ?? 0,
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field>
|
||
<FieldLabel>ตำแหน่งภาพ</FieldLabel>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={resetPosterPositions}
|
||
>
|
||
<RotateCcwIcon data-icon="inline-start" />
|
||
รีเซ็ตตำแหน่งตัวละครและ Fade
|
||
</Button>
|
||
<FieldDescription>
|
||
เลือกเลเยอร์ใต้ตัวอย่าง แล้วลากในตัวอย่างเพื่อเลื่อนตำแหน่ง
|
||
</FieldDescription>
|
||
</Field>
|
||
<Field>
|
||
<FieldLabel>Role</FieldLabel>
|
||
<ToggleGroup
|
||
value={[role]}
|
||
onValueChange={(value) => {
|
||
const selected = value[0] as OverviewRole | undefined;
|
||
if (selected) setRole(selected);
|
||
}}
|
||
variant="outline"
|
||
className="flex-wrap justify-start"
|
||
>
|
||
{overviewRoles.map((value) => (
|
||
<ToggleGroupItem key={value} value={value}>
|
||
{value}
|
||
</ToggleGroupItem>
|
||
))}
|
||
</ToggleGroup>
|
||
</Field>
|
||
{role === "Custom" ? (
|
||
<Field>
|
||
<FieldLabel htmlFor="overview-custom-role">
|
||
ข้อความ Custom Role
|
||
</FieldLabel>
|
||
<Input
|
||
id="overview-custom-role"
|
||
value={customRoleText}
|
||
maxLength={32}
|
||
placeholder="เช่น Buffer"
|
||
onChange={(event) => setCustomRoleText(event.target.value)}
|
||
/>
|
||
</Field>
|
||
) : null}
|
||
<Field>
|
||
<FieldLabel>Blend mode</FieldLabel>
|
||
<DndContext
|
||
sensors={blendLayerSensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={handleBlendLayerDragEnd}
|
||
>
|
||
<SortableContext
|
||
items={blendLayers.map((layer) => layer.id)}
|
||
strategy={verticalListSortingStrategy}
|
||
>
|
||
<div className="flex flex-col gap-3">
|
||
{blendLayers.map((layer) => (
|
||
<SortableBlendLayerRow
|
||
key={layer.id}
|
||
layer={layer}
|
||
onUpdate={(patch) => updateBlendLayer(layer.id, patch)}
|
||
onRemove={() => removeBlendLayer(layer.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</SortableContext>
|
||
</DndContext>
|
||
<div className="mt-3">
|
||
<Button type="button" variant="outline" onClick={addBlendLayer}>
|
||
<PlusIcon data-icon="inline-start" />
|
||
เพิ่ม Blend mode
|
||
</Button>
|
||
</div>
|
||
<FieldDescription>
|
||
เพิ่มได้หลายแบบ และลากที่ไอคอนเพื่อจัดลำดับการผสมสี
|
||
</FieldDescription>
|
||
</Field>
|
||
</FieldGroup>
|
||
</CardContent>
|
||
<CardFooter className="flex flex-col gap-2">
|
||
<Button
|
||
className="w-full"
|
||
disabled={!ready || exporting}
|
||
onClick={() => void download()}
|
||
>
|
||
{exporting ? (
|
||
<Spinner data-icon="inline-start" />
|
||
) : (
|
||
<DownloadIcon data-icon="inline-start" />
|
||
)}
|
||
{exporting ? "กำลังส่งออก…" : "ดาวน์โหลด PNG 3000×3000"}
|
||
</Button>
|
||
</CardFooter>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card className="order-2 min-w-0 lg:col-start-2 lg:row-start-1">
|
||
<CardHeader>
|
||
<CardTitle>ตัวอย่าง</CardTitle>
|
||
<CardDescription>
|
||
ภาพดาวน์โหลดจะใช้เลย์เอาต์เดียวกับตัวอย่าง
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="mx-auto aspect-square w-full max-w-3xl overflow-hidden rounded-lg border">
|
||
<OverviewPoster
|
||
composition={composition}
|
||
artworkUrl={artworkUrl}
|
||
character={character}
|
||
role={displayRole}
|
||
topColors={[topColorStart, topColorEnd]}
|
||
bottomColors={[bottomColorStart, bottomColorEnd]}
|
||
elementColors={[elementColorStart, elementColorEnd]}
|
||
elementGradientAngle={elementGradientAngle}
|
||
blendLayers={blendLayers}
|
||
nameCardCrop={nameCardCrop}
|
||
nameCardSize={nameCardSize}
|
||
elementIconUrls={elementIconUrls}
|
||
interactive
|
||
activeLayer="character"
|
||
onLayerMove={movePosterLayer}
|
||
/>
|
||
</div>
|
||
<Field className="mt-4">
|
||
<FieldLabel htmlFor="overview-fade-y">
|
||
ตำแหน่ง Fade แนวตั้ง {fadeOffset.y}%
|
||
</FieldLabel>
|
||
<Slider
|
||
id="overview-fade-y"
|
||
min={-20}
|
||
max={20}
|
||
step={0.1}
|
||
value={[fadeOffset.y]}
|
||
onValueChange={(value) =>
|
||
setFadeOffset((current) => ({
|
||
...current,
|
||
y: typeof value === "number" ? value : value[0] ?? 2,
|
||
}))
|
||
}
|
||
/>
|
||
<FieldDescription>
|
||
ลากในตัวอย่างเพื่อเลื่อนตัวละคร และใช้แถบนี้เพื่อเลื่อน Fade
|
||
</FieldDescription>
|
||
</Field>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="order-4 lg:col-span-2 lg:col-start-1 lg:row-start-2">
|
||
<CardHeader>
|
||
<CardTitle>สีธีม</CardTitle>
|
||
<CardDescription>
|
||
สีขอบบนและล่างจะตรวจจากภาพตัวละครโดยอัตโนมัติเมื่ออัปโหลด และแก้ไขเองได้
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<FieldGroup className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||
<ColorField
|
||
id="overview-top-color-start"
|
||
label="สีขอบบน เริ่มต้น"
|
||
value={topColorStart}
|
||
onChange={setTopColorStart}
|
||
/>
|
||
<ColorField
|
||
id="overview-top-color-end"
|
||
label="สีขอบบน สิ้นสุด"
|
||
value={topColorEnd}
|
||
onChange={setTopColorEnd}
|
||
/>
|
||
<ColorField
|
||
id="overview-bottom-color-start"
|
||
label="สีขอบล่าง เริ่มต้น"
|
||
value={bottomColorStart}
|
||
onChange={setBottomColorStart}
|
||
/>
|
||
<ColorField
|
||
id="overview-bottom-color-end"
|
||
label="สีขอบล่าง สิ้นสุด"
|
||
value={bottomColorEnd}
|
||
onChange={setBottomColorEnd}
|
||
/>
|
||
<ColorField
|
||
id="overview-element-color-start"
|
||
label="สีไล่ระดับธาตุ เริ่มต้น"
|
||
value={elementColorStart}
|
||
onChange={setElementColorStart}
|
||
/>
|
||
<ColorField
|
||
id="overview-element-color-end"
|
||
label="สีไล่ระดับธาตุ สิ้นสุด"
|
||
value={elementColorEnd}
|
||
onChange={setElementColorEnd}
|
||
/>
|
||
<Field>
|
||
<FieldLabel htmlFor="overview-element-gradient-angle">
|
||
มุมสีไล่ระดับธาตุ {elementGradientAngle}°
|
||
</FieldLabel>
|
||
<Slider
|
||
id="overview-element-gradient-angle"
|
||
min={0}
|
||
max={360}
|
||
step={1}
|
||
value={[elementGradientAngle]}
|
||
onValueChange={(value) =>
|
||
setElementGradientAngle(
|
||
typeof value === "number" ? value : value[0] ?? 0,
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
</FieldGroup>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div
|
||
ref={captureRef}
|
||
aria-hidden="true"
|
||
className="pointer-events-none fixed top-0 left-[-10000px] order-5 size-250"
|
||
>
|
||
<OverviewPoster
|
||
composition={composition}
|
||
artworkUrl={artworkUrl}
|
||
character={character}
|
||
role={displayRole}
|
||
topColors={[topColorStart, topColorEnd]}
|
||
bottomColors={[bottomColorStart, bottomColorEnd]}
|
||
elementColors={[elementColorStart, elementColorEnd]}
|
||
elementGradientAngle={elementGradientAngle}
|
||
blendLayers={blendLayers}
|
||
nameCardCrop={nameCardCrop}
|
||
nameCardSize={nameCardSize}
|
||
elementIconUrls={elementIconUrls}
|
||
/>
|
||
</div>
|
||
|
||
<Dialog
|
||
open={Boolean(pendingCrop)}
|
||
onOpenChange={(open) => {
|
||
if (!open) setPendingCrop(null);
|
||
}}
|
||
>
|
||
<DialogContent className="sm:max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>ครอปภาพเป็น 1:1</DialogTitle>
|
||
<DialogDescription>
|
||
เลื่อนตำแหน่งภาพให้ส่วนที่ต้องการอยู่ภายในกรอบสี่เหลี่ยม
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
{pendingCrop ? (
|
||
<div className="flex flex-col gap-5">
|
||
<Cropper
|
||
key={pendingCrop.url}
|
||
image={pendingCrop.url}
|
||
aspectRatio={1}
|
||
zoom={cropZoom}
|
||
onCropChange={setCropArea}
|
||
onZoomChange={setCropZoom}
|
||
className="h-96 rounded-lg bg-black sm:h-120"
|
||
>
|
||
<CropperDescription>
|
||
ลากภาพหรือใช้ปุ่มลูกศรเพื่อเลือกพื้นที่ครอปสี่เหลี่ยม
|
||
</CropperDescription>
|
||
<CropperImage />
|
||
<CropperCropArea />
|
||
</Cropper>
|
||
<div className="flex justify-center gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => rotateArtwork(-90)}
|
||
>
|
||
<RotateCcwIcon data-icon="inline-start" />
|
||
หมุนซ้าย
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => rotateArtwork(90)}
|
||
>
|
||
<RotateCwIcon data-icon="inline-start" />
|
||
หมุนขวา
|
||
</Button>
|
||
</div>
|
||
<Field>
|
||
<FieldLabel htmlFor="crop-zoom">ซูม</FieldLabel>
|
||
<div className="flex items-center gap-3">
|
||
<ZoomOutIcon
|
||
aria-hidden="true"
|
||
className="shrink-0 opacity-60"
|
||
/>
|
||
<Input
|
||
id="crop-zoom"
|
||
type="range"
|
||
min={1}
|
||
max={3}
|
||
step={0.1}
|
||
value={cropZoom}
|
||
onChange={(event) =>
|
||
setCropZoom(Number(event.target.value))
|
||
}
|
||
/>
|
||
<ZoomInIcon
|
||
aria-hidden="true"
|
||
className="shrink-0 opacity-60"
|
||
/>
|
||
</div>
|
||
</Field>
|
||
</div>
|
||
) : null}
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setPendingCrop(null)}>
|
||
ยกเลิก
|
||
</Button>
|
||
<Button disabled={!cropArea} onClick={applyCrop}>
|
||
<CropIcon data-icon="inline-start" />
|
||
ใช้ภาพที่ครอป
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={nameCardCropOpen} onOpenChange={setNameCardCropOpen}>
|
||
<DialogContent className="sm:max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>ครอปพื้นหลัง Namecard</DialogTitle>
|
||
<DialogDescription>
|
||
ลากและซูม Namecard เพื่อเลือกพื้นที่ด้านหลังตัวละคร
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
{character?.nameCardImageUrl ? (
|
||
<div className="flex flex-col gap-5">
|
||
<Cropper
|
||
image={character.nameCardImageUrl}
|
||
aspectRatio={1}
|
||
zoom={nameCardZoom}
|
||
onCropChange={setNameCardDraftCrop}
|
||
onZoomChange={setNameCardZoom}
|
||
className="h-96 rounded-lg bg-black sm:h-120"
|
||
>
|
||
<CropperDescription>
|
||
ลากภาพหรือใช้ปุ่มลูกศรเพื่อเลือกพื้นหลังสี่เหลี่ยม
|
||
</CropperDescription>
|
||
<CropperImage />
|
||
<CropperCropArea />
|
||
</Cropper>
|
||
<Field>
|
||
<FieldLabel htmlFor="namecard-zoom">ซูม</FieldLabel>
|
||
<div className="flex items-center gap-3">
|
||
<ZoomOutIcon
|
||
aria-hidden="true"
|
||
className="shrink-0 opacity-60"
|
||
/>
|
||
<Input
|
||
id="namecard-zoom"
|
||
type="range"
|
||
min={1}
|
||
max={3}
|
||
step={0.1}
|
||
value={nameCardZoom}
|
||
onChange={(event) =>
|
||
setNameCardZoom(Number(event.target.value))
|
||
}
|
||
/>
|
||
<ZoomInIcon
|
||
aria-hidden="true"
|
||
className="shrink-0 opacity-60"
|
||
/>
|
||
</div>
|
||
</Field>
|
||
</div>
|
||
) : null}
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setNameCardCropOpen(false)}
|
||
>
|
||
ยกเลิก
|
||
</Button>
|
||
<Button
|
||
disabled={!nameCardDraftCrop}
|
||
onClick={() => {
|
||
if (!nameCardDraftCrop) return;
|
||
setNameCardCrop(nameCardDraftCrop);
|
||
setNameCardCropOpen(false);
|
||
}}
|
||
>
|
||
<CropIcon data-icon="inline-start" />
|
||
ใช้ตำแหน่งนี้
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|