Files
gunshiz 316a1eecfa
CI / Build immutable images and deploy (push) Has been cancelled
CI / Verify (push) Has been cancelled
fix(ui) : keep technical labels English and compact badges
2026-09-14 17:43:29 +07:00

261 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useTranslation } from "@/components/i18n/locale-provider";
import Image from "next/image";
import Link from "next/link";
import { useRef, type PointerEvent } from "react";
import type {
CatalogAscensionGroup,
CatalogBaseStat,
} from "@/lib/catalog/lunaris";
import { mergePosterMaterials, type PosterMaterial } from "@/lib/material-poster";
import { accentGradient } from "@/lib/overview-image";
import { exportText, type Locale } from "@/lib/guides/export-text";
import { cn } from "@/lib/utils";
export type PosterBlendMode = "normal" | "multiply" | "screen" | "overlay" | "darken" | "lighten" | "color-dodge" | "color-burn" | "hard-light" | "soft-light" | "difference" | "exclusion" | "hue" | "saturation" | "color" | "luminosity";
type PosterCharacter = {
name: string;
nameCardImageUrl: string | null;
baseStats: CatalogBaseStat[];
ascension: Array<Omit<CatalogAscensionGroup, "items"> & { items: PosterMaterial[] }>;
leveling: Array<Omit<CatalogAscensionGroup, "items"> & { items: PosterMaterial[] }>;
};
const POSTER_EXPORT_WIDTH = 1600;
const POSTER_IMAGE_QUALITY = 100;
const MAX_OPTIMIZED_IMAGE_WIDTH = 3840;
const statLabels: Record<string, string> = {
hp: "HP",
atk: "ATK",
def: "DEF",
em: "EM",
};
function statLabel(key: string): string {
return statLabels[key.toLowerCase()] ?? key;
}
function statIconName(key: string): string | null {
const normalized = key.toLowerCase().replace(/[^a-z]/gu, "");
if (["hp", "atk", "def", "em", "er"].includes(normalized)) return normalized;
if (["critrate", "crit", "criticalrate"].includes(normalized)) return "critrate";
if (["critdmg", "critdamage", "criticaldamage"].includes(normalized)) return "critdmg";
return null;
}
function statValue(key: string, value: number | string): string {
const formatted = typeof value === "number"
? value.toLocaleString("en-US", { maximumFractionDigits: 2 })
: value;
return key.includes("%") ? `${formatted}%` : formatted;
}
export function MaterialPoster({
character,
characterArtUrl,
artworkOffsetX,
artworkOffsetY,
artworkScale,
topColors,
bottomColors,
interactive = false,
linkMaterials = false,
onArtworkMove,
className,
bleeding = [],
locale: overrideLocale,
}: {
locale?: Locale;
character: PosterCharacter;
characterArtUrl: string | null;
artworkOffsetX: number;
artworkOffsetY: number;
topColors: [string, string];
bottomColors: [string, string];
interactive?: boolean;
linkMaterials?: boolean;
onArtworkMove?: (offset: { x: number; y: number }) => void;
className?: string;
artworkScale: number;
bleeding?: Array<{ id: string; imageUrl: string | null; amount: number; blendMode: PosterBlendMode }>;
}) {
const { t, locale: selectedLocale } = useTranslation();
const locale = overrideLocale ?? selectedLocale;
const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null);
const stats = character.baseStats.slice(0, 4);
const materials = mergePosterMaterials(
[...character.ascension, ...character.leveling].flatMap((group) => group.items),
);
const artworkImageSize = Math.min(
MAX_OPTIMIZED_IMAGE_WIDTH,
Math.max(POSTER_EXPORT_WIDTH, Math.ceil(POSTER_EXPORT_WIDTH * artworkScale / 100)),
);
const background = `linear-gradient(180deg, ${topColors[0]} 0%, ${topColors[1]} 22%, rgba(0,0,0,0) 42%, rgba(0,0,0,0) 62%, ${bottomColors[0]} 88%, ${bottomColors[1]} 100%)`;
function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
if (!interactive || !characterArtUrl || !onArtworkMove) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragStart.current = {
pointerId: event.pointerId,
x: event.clientX,
y: event.clientY,
offsetX: artworkOffsetX,
offsetY: artworkOffsetY,
};
}
function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
if (!dragStart.current || dragStart.current.pointerId !== event.pointerId || !onArtworkMove) return;
const rect = event.currentTarget.getBoundingClientRect();
onArtworkMove({
x: dragStart.current.offsetX + ((event.clientX - dragStart.current.x) / rect.width) * 100,
y: dragStart.current.offsetY + ((event.clientY - dragStart.current.y) / rect.height) * 100,
});
}
function handlePointerUp(event: PointerEvent<HTMLDivElement>) {
if (dragStart.current?.pointerId === event.pointerId) dragStart.current = null;
}
return (
<div
className={cn("@container relative aspect-[4/5] w-full select-none overflow-hidden bg-black text-white", interactive && characterArtUrl && "cursor-move", className)}
style={{ backgroundImage: background }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
role={interactive && characterArtUrl ? "application" : undefined}
aria-label={interactive && characterArtUrl ? t("ลากเพื่อเลื่อนภาพตัวละคร") : undefined}
>
{character.nameCardImageUrl ? (
<Image
src={character.nameCardImageUrl}
alt=""
fill
sizes={`${POSTER_EXPORT_WIDTH}px`}
quality={POSTER_IMAGE_QUALITY}
className="object-cover opacity-75"
priority={!interactive}
/>
) : null}
<div className="absolute inset-0 bg-black/55" />
{characterArtUrl ? (
<div
className="pointer-events-none absolute inset-0"
style={{ transform: `translate(${artworkOffsetX}%, ${artworkOffsetY}%) scale(${artworkScale / 100})` }}
>
<Image
src={characterArtUrl}
alt={character.name}
fill
unoptimized
sizes={`${artworkImageSize}px`}
quality={POSTER_IMAGE_QUALITY}
className="object-contain object-center"
priority={!interactive}
/>
{[...bleeding].reverse().map((layer) => (
<Image
key={layer.id}
src={characterArtUrl}
alt=""
fill
unoptimized
sizes={`${artworkImageSize}px`}
quality={POSTER_IMAGE_QUALITY}
className="object-contain object-center"
style={{ opacity: Math.max(0, Math.min(100, layer.amount)) / 100, mixBlendMode: layer.blendMode }}
/>
))}
</div>
) : null}
<Image
src="/img/mat/fade.png"
alt=""
fill
sizes={`${POSTER_EXPORT_WIDTH}px`}
quality={POSTER_IMAGE_QUALITY}
className="pointer-events-none object-cover opacity-50"
priority={!interactive}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/10 via-transparent to-black/80" />
<div
className="pointer-events-none absolute inset-x-0 top-0 h-[1.35cqw]"
style={{ backgroundImage: accentGradient(...topColors) }}
/>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-[1.35cqw]"
style={{ backgroundImage: accentGradient(...bottomColors) }}
/>
<div className="absolute inset-x-[4%] top-[5%]" style={{ fontFamily: "Kanit Poster", fontWeight: 800, fontStyle: "italic" }}>
<h2 className="text-[7cqw] leading-none" style={{ textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}>BASE STATS</h2>
<div className="mt-[4%] flex w-[53%] flex-col gap-[1.2cqw]">
{Array.from({ length: 4 }, (_, index) => {
const stat = stats[index];
const iconName = stat ? statIconName(stat.key) : null;
return (
<div key={stat?.key ?? index} className="flex h-[7cqw] items-center justify-between rounded-[1.5cqw] bg-black/95 px-[4%] text-[3.2cqw] leading-none">
<span className="flex min-w-0 items-center gap-[1cqw] whitespace-nowrap">
{iconName ? (
<Image
src={`/icon/stats/${iconName}.webp`}
alt=""
width={64}
height={64}
quality={POSTER_IMAGE_QUALITY}
className="size-[3.5cqw] shrink-0 object-contain brightness-0 invert"
/>
) : null}
<span>{stat ? statLabel(stat.key) : "—"}</span>
</span>
<span className="whitespace-nowrap text-[2.7cqw]">{stat ? statValue(stat.key, stat.value) : "—"}</span>
</div>
);
})}
</div>
</div>
<div className="absolute inset-x-[4%] top-[49%]" style={{ fontFamily: "Kanit Poster", fontWeight: 800, fontStyle: "italic" }}>
<h2 className="text-[7cqw] leading-none" style={{ textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}>{exportText("materials", {}, locale)}</h2>
<p className="mt-[1%] text-[4.5cqw] leading-none">{exportText("materialLevel", { level: 90 }, locale)}</p>
<div className="mt-[5%] grid grid-cols-8 gap-[1.2%]">
{materials.map((material, index) => {
const materialCard = (
<>
<div className="flex h-[68%] items-center justify-center rounded-br-[1.3cqw] bg-[#c1c1c1] p-[8%]">
{material.imageUrl ? <Image src={material.imageUrl} alt="" width={192} height={192} quality={POSTER_IMAGE_QUALITY} className="size-full object-contain" /> : null}
</div>
<div className="flex h-[32%] items-center justify-center px-[4%] text-center text-[2.2cqw] leading-none">
<span className="whitespace-nowrap text-[1.8cqw]">×{material.amount.toLocaleString("en-US")}</span>
</div>
</>
);
const className = "aspect-[0.72] overflow-hidden rounded-[1.3cqw] bg-black/95 ring-1 ring-white/10";
return linkMaterials ? (
<Link
key={`${material.key}-${index}`}
href={`https://lunaris.moe/material/${encodeURIComponent(material.key)}`}
className={className}
aria-label={t("เปิดข้อมูล {name} บน Lunaris", { name: material.name })}
>
{materialCard}
</Link>
) : (
<div key={`${material.key}-${index}`} className={className}>
{materialCard}
</div>
);
})}
</div>
</div>
</div>
);
}