feat : add image gen
This commit is contained in:
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
ImageIcon,
|
||||
LayoutDashboardIcon,
|
||||
BookOpenIcon,
|
||||
LogOutIcon,
|
||||
@@ -66,6 +67,17 @@ export function AdminHeader() {
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
<span className="hidden sm:inline">New Guide</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
pathname === "/admin/generate/image" ? "secondary" : "ghost"
|
||||
}
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link href="/admin/generate/image" />}
|
||||
>
|
||||
<ImageIcon data-icon="inline-start" />
|
||||
<span className="hidden sm:inline">Image Generator</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
|
||||
@@ -0,0 +1,899 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import {
|
||||
CropIcon,
|
||||
DownloadIcon,
|
||||
ImageIcon,
|
||||
RotateCcwIcon,
|
||||
RotateCwIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type Ref,
|
||||
} 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,
|
||||
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,
|
||||
elementOrder,
|
||||
normalizeHex,
|
||||
overviewFileName,
|
||||
overviewRoles,
|
||||
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;
|
||||
};
|
||||
|
||||
type CropArea = { x: number; y: number; width: number; height: number };
|
||||
|
||||
const DISCLAIMER =
|
||||
"ไม่สามารถเอาไปเปรียบเทียบกับการคำนวณของ TC ท่านอื่นๆได้\nเพราะแต่ละคนใช้สแตทในการคำนวณที่ไม่เหมือนกัน\nและตารางที่เห็นอาจมีการเปลี่ยนแปลงได้ในอนาคต";
|
||||
|
||||
function OverviewPoster({
|
||||
artworkUrl,
|
||||
character,
|
||||
role,
|
||||
topColors,
|
||||
bottomColors,
|
||||
nameCardCrop,
|
||||
nameCardSize,
|
||||
elementIconUrls,
|
||||
captureRef,
|
||||
}: {
|
||||
artworkUrl: string | null;
|
||||
character: Character | null;
|
||||
role: OverviewRole;
|
||||
topColors: [string, string];
|
||||
bottomColors: [string, string];
|
||||
nameCardCrop: CropArea | null;
|
||||
nameCardSize: { width: number; height: number } | null;
|
||||
elementIconUrls: Record<string, string | null>;
|
||||
captureRef?: Ref<HTMLDivElement>;
|
||||
}) {
|
||||
const weaponIcon = weaponTypeIconPath(character?.weaponType ?? null);
|
||||
const normalizedBottom = normalizeHex(bottomColors[1]) ?? "#E9A51A";
|
||||
const nameSize = (character?.name.length ?? 0) > 16 ? "5.6cqw" : "7.2cqw";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={captureRef}
|
||||
className="relative isolate aspect-square size-full overflow-hidden bg-black text-white [container-type:inline-size]"
|
||||
style={{ fontFamily: '"Genshin Overview", sans-serif' }}
|
||||
>
|
||||
{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}%`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={character.nameCardImageUrl}
|
||||
alt=""
|
||||
fill
|
||||
loading="eager"
|
||||
sizes="1024px"
|
||||
className="object-cover"
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
{artworkUrl ? (
|
||||
<>
|
||||
<Image
|
||||
src={artworkUrl}
|
||||
alt=""
|
||||
fill
|
||||
unoptimized
|
||||
loading="eager"
|
||||
sizes="1024px"
|
||||
className="object-cover"
|
||||
/>
|
||||
<Image
|
||||
src={artworkUrl}
|
||||
alt=""
|
||||
fill
|
||||
unoptimized
|
||||
loading="eager"
|
||||
sizes="1024px"
|
||||
className="object-cover mix-blend-color-burn"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0 grid place-items-center bg-muted text-muted-foreground">
|
||||
<ImageIcon aria-hidden="true" className="size-[12cqw]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Image
|
||||
src="/img/fade.png"
|
||||
alt=""
|
||||
fill
|
||||
unoptimized
|
||||
loading="eager"
|
||||
sizes="1024px"
|
||||
className="object-fill"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: accentGradient(...topColors),
|
||||
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) }}
|
||||
/>
|
||||
|
||||
<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">
|
||||
{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>
|
||||
|
||||
{character ? (
|
||||
<div className="absolute inset-x-[5cqw] top-[69cqw] flex flex-col items-center text-center">
|
||||
<p
|
||||
className="max-w-full truncate leading-none font-normal tracking-[0.04em]"
|
||||
style={{
|
||||
fontSize: `clamp(4rem, 8cqi, 2.5rem)`,
|
||||
textShadow: `0 0 0.8cqw ${normalizedBottom}`,
|
||||
}}
|
||||
>
|
||||
{character.name.toLocaleUpperCase("en")}
|
||||
</p>
|
||||
<div className="mt-[2.1cqw] flex items-center justify-center gap-[1.5cqw]">
|
||||
{elementOrder.map((element) => {
|
||||
const url = elementIconUrls[element];
|
||||
const active = element === character.element;
|
||||
return url ? (
|
||||
<Image
|
||||
key={element}
|
||||
src={url}
|
||||
alt=""
|
||||
width={72}
|
||||
height={72}
|
||||
loading="eager"
|
||||
sizes="5cqw"
|
||||
className={cn(
|
||||
"size-[4.7cqw] object-contain",
|
||||
!active && "brightness-0 invert opacity-30",
|
||||
)}
|
||||
style={
|
||||
active
|
||||
? {
|
||||
filter: `${elementIconFilter(element) ?? ""} drop-shadow(0 0 0.5cqw ${normalizedBottom})`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-[2.3cqw] whitespace-pre-line text-[2.15cqw] leading-[1.15]">
|
||||
{DISCLAIMER}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
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>กรุณาใส่สีรูปแบบ #RRGGBB</FieldError> : null}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
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 [topColorStart, setTopColorStart] = useState("#E9A51A");
|
||||
const [topColorEnd, setTopColorEnd] = useState("#FFD84A");
|
||||
const [bottomColorStart, setBottomColorStart] = useState("#E9A51A");
|
||||
const [bottomColorEnd, setBottomColorEnd] = useState("#FFD84A");
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (artworkUrl) URL.revokeObjectURL(artworkUrl);
|
||||
},
|
||||
[artworkUrl],
|
||||
);
|
||||
|
||||
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>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const image = new window.Image();
|
||||
image.onload = () => resolve();
|
||||
image.onerror = reject;
|
||||
image.src = url;
|
||||
});
|
||||
setArtworkError(null);
|
||||
setCropArea(null);
|
||||
setCropZoom(1);
|
||||
setPendingCrop({ url });
|
||||
} 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,
|
||||
);
|
||||
const blob = await new Promise<Blob>((resolve, reject) =>
|
||||
canvas.toBlob(
|
||||
(value) => (value ? resolve(value) : reject(new Error("Crop failed"))),
|
||||
"image/png",
|
||||
),
|
||||
);
|
||||
setArtworkUrl(URL.createObjectURL(blob));
|
||||
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) });
|
||||
} catch {
|
||||
toast.error("หมุนภาพไม่สำเร็จ กรุณาลองอีกครั้ง");
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
const node = captureRef.current;
|
||||
if (
|
||||
!node ||
|
||||
!character ||
|
||||
!artworkUrl ||
|
||||
!normalizeHex(topColorStart) ||
|
||||
!normalizeHex(topColorEnd) ||
|
||||
!normalizeHex(bottomColorStart) ||
|
||||
!normalizeHex(bottomColorEnd)
|
||||
)
|
||||
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: 1024,
|
||||
height: 1024,
|
||||
scale: 4,
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = overviewFileName(character.key, role);
|
||||
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) &&
|
||||
weaponTypeIconPath(character.weaponType),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid items-start gap-6 lg:grid-cols-[minmax(18rem,24rem)_minmax(0,1fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ตั้งค่าภาพ</CardTitle>
|
||||
<CardDescription>ไฟล์ภาพจะใช้เฉพาะในเบราว์เซอร์นี้</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel>ตัวละคร</FieldLabel>
|
||||
<Combobox
|
||||
items={characters}
|
||||
value={character}
|
||||
onValueChange={(value) => {
|
||||
setCharacter(value);
|
||||
setNameCardCrop(null);
|
||||
setNameCardSize(null);
|
||||
setNameCardCropOpen(false);
|
||||
}}
|
||||
itemToStringLabel={(item) => item.name}
|
||||
itemToStringValue={(item) => item.key}
|
||||
filter={(item, query) =>
|
||||
[item.name, ...item.aliases]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("th")
|
||||
.includes(query.trim().toLocaleLowerCase("th"))
|
||||
}
|
||||
autoHighlight
|
||||
>
|
||||
<ComboboxInput className="w-full" placeholder="ค้นหาตัวละคร…" />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>ไม่พบตัวละคร</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>
|
||||
</Combobox>
|
||||
<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={selectArtwork}
|
||||
/>
|
||||
<FieldDescription>
|
||||
ทุกภาพจะเปิดหน้าต่างให้ครอป ซูม และจัดตำแหน่งก่อนใช้งาน
|
||||
</FieldDescription>
|
||||
{artworkError ? <FieldError>{artworkError}</FieldError> : null}
|
||||
</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>
|
||||
|
||||
<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}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full" disabled={!ready || exporting} onClick={download}>
|
||||
{exporting ? (
|
||||
<Spinner data-icon="inline-start" />
|
||||
) : (
|
||||
<DownloadIcon data-icon="inline-start" />
|
||||
)}
|
||||
{exporting ? "กำลังสร้าง PNG…" : "ดาวน์โหลด PNG 4096×4096"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ตัวอย่าง</CardTitle>
|
||||
<CardDescription>ภาพดาวน์โหลดจะใช้เลย์เอาต์เดียวกับตัวอย่าง</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mx-auto aspect-square w-full max-w-3xl overflow-hidden rounded-lg border">
|
||||
<OverviewPoster
|
||||
artworkUrl={artworkUrl}
|
||||
character={character}
|
||||
role={role}
|
||||
topColors={[topColorStart, topColorEnd]}
|
||||
bottomColors={[bottomColorStart, bottomColorEnd]}
|
||||
nameCardCrop={nameCardCrop}
|
||||
nameCardSize={nameCardSize}
|
||||
elementIconUrls={elementIconUrls}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none fixed top-0 -left-[10000px] size-256"
|
||||
>
|
||||
<OverviewPoster
|
||||
artworkUrl={artworkUrl}
|
||||
character={character}
|
||||
role={role}
|
||||
topColors={[topColorStart, topColorEnd]}
|
||||
bottomColors={[bottomColorStart, bottomColorEnd]}
|
||||
nameCardCrop={nameCardCrop}
|
||||
nameCardSize={nameCardSize}
|
||||
elementIconUrls={elementIconUrls}
|
||||
captureRef={captureRef}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -168,7 +168,7 @@ function TeamExportImage({ team }: { team: Team }) {
|
||||
const percent = total > 0 ? damage / total * 100 : 0;
|
||||
return (
|
||||
<div key={member.position} className="flex h-43.5 items-center gap-6 rounded-3xl p-3.5" style={{ backgroundColor: elementBackgroundColor(member.character.element) }}>
|
||||
<div className={cn("relative size-35] shrink-0 overflow-hidden rounded-3xl border-[3px] border-white/20", rarityGradientClass(isCustomTravelerKey(member.character.key) ? 5 : member.character.rarity))}>
|
||||
<div className={cn("relative size-35 shrink-0 overflow-hidden rounded-3xl border-[3px] border-white/20", rarityGradientClass(isCustomTravelerKey(member.character.key) ? 5 : member.character.rarity))}>
|
||||
<Image src={member.character.imageUrl} alt="" fill loading="eager" sizes="140px" className="object-contain" />
|
||||
<span className="absolute top-1.5 left-1.5 text-xl font-bold">C{member.constellation}</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Cropper as CropperPrimitive } from "@origin-space/image-cropper";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Cropper({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CropperPrimitive.Root>) {
|
||||
return (
|
||||
<CropperPrimitive.Root
|
||||
className={cn(
|
||||
"relative flex w-full cursor-move touch-none items-center justify-center overflow-hidden focus:outline-none",
|
||||
className,
|
||||
)}
|
||||
data-slot="cropper"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CropperDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CropperPrimitive.Description>) {
|
||||
return (
|
||||
<CropperPrimitive.Description
|
||||
className={cn("sr-only", className)}
|
||||
data-slot="cropper-description"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CropperImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CropperPrimitive.Image>) {
|
||||
return (
|
||||
<CropperPrimitive.Image
|
||||
className={cn(
|
||||
"pointer-events-none h-full w-full object-cover",
|
||||
className,
|
||||
)}
|
||||
data-slot="cropper-image"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CropperCropArea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CropperPrimitive.CropArea>) {
|
||||
return (
|
||||
<CropperPrimitive.CropArea
|
||||
className={cn(
|
||||
"pointer-events-none absolute border-3 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.45)] in-[[data-slot=cropper]:focus-visible]:ring-3 in-[[data-slot=cropper]:focus-visible]:ring-white/50",
|
||||
className,
|
||||
)}
|
||||
data-slot="cropper-crop-area"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Cropper, CropperCropArea, CropperDescription, CropperImage };
|
||||
Reference in New Issue
Block a user