feat : add image gen
@@ -0,0 +1,56 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
|
||||
import { AdminHeader } from "@/components/admin/admin-header";
|
||||
import { OverviewImageGenerator } from "@/components/admin/overview-image-generator";
|
||||
import { getAdminSession } from "@/lib/auth/server";
|
||||
import { getActiveCatalog } from "@/lib/guides/queries";
|
||||
import { elementOrder } from "@/lib/overview-image";
|
||||
|
||||
export default async function OverviewImagePage() {
|
||||
await connection();
|
||||
if (!(await getAdminSession())) redirect("/admin/login");
|
||||
|
||||
const catalog = await getActiveCatalog();
|
||||
if (!catalog) redirect("/admin?sync=required");
|
||||
|
||||
const elementIconUrls = Object.fromEntries(
|
||||
elementOrder.map((element) => [
|
||||
element,
|
||||
catalog.characters.find(
|
||||
(character) =>
|
||||
character.element === element && character.elementIconUrl,
|
||||
)?.elementIconUrl ?? null,
|
||||
]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<AdminHeader />
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6 lg:p-8">
|
||||
<header>
|
||||
<p className="text-sm font-medium text-primary">Image generator</p>
|
||||
<h1 className="font-heading text-3xl font-semibold">
|
||||
สร้างภาพ Overview
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
สร้างและดาวน์โหลดภาพ PNG ขนาด 4K โดยไม่เปลี่ยนภาพใน Guide
|
||||
</p>
|
||||
</header>
|
||||
<OverviewImageGenerator
|
||||
characters={catalog.characters.map((character) => ({
|
||||
key: character.key,
|
||||
name: character.name,
|
||||
aliases: character.aliases,
|
||||
element: character.element,
|
||||
rarity: character.rarity,
|
||||
weaponType: character.weaponType,
|
||||
imageUrl: character.imageUrl,
|
||||
nameCardImageUrl: character.nameCardImageUrl,
|
||||
}))}
|
||||
elementIconUrls={elementIconUrls}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,12 @@
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@font-face {
|
||||
font-family: "Genshin Overview";
|
||||
src: url("/zh-cn.ttf") format("truetype");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@origin-space/image-cropper": "^0.1.9",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/extension-link": "3.31.3",
|
||||
@@ -380,6 +381,8 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/[email protected]", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@origin-space/image-cropper": ["@origin-space/[email protected]", "", { "peerDependencies": { "react": "^19.1.0", "react-dom": "^19.1.0" } }, "sha512-Ebl57I6beh7uKjSv8+r3VObEoOXIXhx4SicDUdxa9U7NVY18SzEGfqPDuhNyXuFIB5G/S8nYUToNlIRp9wwB9Q=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/[email protected]", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/[email protected]", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="],
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
accentGradient,
|
||||
normalizeHex,
|
||||
overviewFileName,
|
||||
weaponTypeIconPath,
|
||||
} from "@/lib/overview-image";
|
||||
|
||||
describe("overview image generator", () => {
|
||||
it("normalizes valid colors and rejects invalid colors", () => {
|
||||
expect(normalizeHex(" #e9a51a ")).toBe("#E9A51A");
|
||||
expect(normalizeHex("#fff")).toBeNull();
|
||||
expect(normalizeHex("orange")).toBeNull();
|
||||
});
|
||||
|
||||
it("creates a deterministic accent gradient", () => {
|
||||
expect(accentGradient("#E9A51A")).toBe(
|
||||
"linear-gradient(90deg, #CD9117 0%, #E9A51A 25%, #F1C56A 50%, #E9A51A 75%, #CD9117 100%)",
|
||||
);
|
||||
expect(accentGradient("#FF0000", "#0000FF")).toBe(
|
||||
"linear-gradient(90deg, #E00000 0%, #FF0000 25%, #AC59AC 50%, #0000FF 75%, #0000E0 100%)",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps every catalog weapon type to a PNG", () => {
|
||||
expect(weaponTypeIconPath("WEAPON_SWORD_ONE_HAND")).toBe(
|
||||
"/icon/weapon-types/sword.png",
|
||||
);
|
||||
expect(weaponTypeIconPath("WEAPON_CLAYMORE")).toBe(
|
||||
"/icon/weapon-types/claymore.png",
|
||||
);
|
||||
expect(weaponTypeIconPath("WEAPON_POLE")).toBe(
|
||||
"/icon/weapon-types/polearm.png",
|
||||
);
|
||||
expect(weaponTypeIconPath("WEAPON_BOW")).toBe(
|
||||
"/icon/weapon-types/bow.png",
|
||||
);
|
||||
expect(weaponTypeIconPath("WEAPON_CATALYST")).toBe(
|
||||
"/icon/weapon-types/catalyst.png",
|
||||
);
|
||||
expect(weaponTypeIconPath(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("creates a safe PNG filename", () => {
|
||||
expect(overviewFileName("Traveler / Pyro", "Main DPS")).toBe(
|
||||
"overview-Traveler-Pyro-main-dps.png",
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
export const overviewRoles = ["Main DPS", "Sub DPS", "Support"] as const;
|
||||
|
||||
export type OverviewRole = (typeof overviewRoles)[number];
|
||||
|
||||
export const elementOrder = [
|
||||
"Pyro",
|
||||
"Hydro",
|
||||
"Anemo",
|
||||
"Electro",
|
||||
"Dendro",
|
||||
"Cryo",
|
||||
"Geo",
|
||||
] as const;
|
||||
|
||||
const weaponIconNames: Record<string, string> = {
|
||||
WEAPON_SWORD_ONE_HAND: "sword.png",
|
||||
WEAPON_CLAYMORE: "claymore.png",
|
||||
WEAPON_POLE: "polearm.png",
|
||||
WEAPON_BOW: "bow.png",
|
||||
WEAPON_CATALYST: "catalyst.png",
|
||||
};
|
||||
|
||||
export function normalizeHex(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!/^#[\da-f]{6}$/iu.test(trimmed)) return null;
|
||||
return trimmed.toUpperCase();
|
||||
}
|
||||
|
||||
function mixHex(hex: string, target: number, amount: number): string {
|
||||
const normalized = normalizeHex(hex);
|
||||
if (!normalized) return hex;
|
||||
const channels = [1, 3, 5].map((offset) =>
|
||||
Number.parseInt(normalized.slice(offset, offset + 2), 16),
|
||||
);
|
||||
return `#${channels
|
||||
.map((channel) =>
|
||||
Math.round(channel + (target - channel) * amount)
|
||||
.toString(16)
|
||||
.padStart(2, "0"),
|
||||
)
|
||||
.join("")}`.toUpperCase();
|
||||
}
|
||||
|
||||
function mixColors(left: string, right: string): string {
|
||||
const leftHex = normalizeHex(left) ?? "#E9A51A";
|
||||
const rightHex = normalizeHex(right) ?? leftHex;
|
||||
const channels = [1, 3, 5].map((offset) =>
|
||||
Math.round(
|
||||
(Number.parseInt(leftHex.slice(offset, offset + 2), 16) +
|
||||
Number.parseInt(rightHex.slice(offset, offset + 2), 16)) /
|
||||
2,
|
||||
),
|
||||
);
|
||||
return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`.toUpperCase();
|
||||
}
|
||||
|
||||
export function accentGradient(start: string, end = start): string {
|
||||
const normalizedStart = normalizeHex(start) ?? "#E9A51A";
|
||||
const normalizedEnd = normalizeHex(end) ?? normalizedStart;
|
||||
const startEdge = mixHex(normalizedStart, 0, 0.12);
|
||||
const endEdge = mixHex(normalizedEnd, 0, 0.12);
|
||||
const highlight = mixHex(mixColors(normalizedStart, normalizedEnd), 255, 0.35);
|
||||
return `linear-gradient(90deg, ${startEdge} 0%, ${normalizedStart} 25%, ${highlight} 50%, ${normalizedEnd} 75%, ${endEdge} 100%)`;
|
||||
}
|
||||
|
||||
export function weaponTypeIconPath(weaponType: string | null): string | null {
|
||||
const fileName = weaponType ? weaponIconNames[weaponType] : undefined;
|
||||
return fileName ? `/icon/weapon-types/${fileName}` : null;
|
||||
}
|
||||
|
||||
export function overviewFileName(characterKey: string, role: OverviewRole): string {
|
||||
const safeKey = characterKey.replace(/[^\p{L}\p{N}._-]+/gu, "-");
|
||||
const safeRole = role.toLowerCase().replaceAll(" ", "-");
|
||||
return `overview-${safeKey}-${safeRole}.png`;
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@origin-space/image-cropper": "^0.1.9",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/extension-link": "3.31.3",
|
||||
|
||||
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 574 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 125 KiB |