Files
buzz-sheet/components/public/material-poster.tsx
T
gunshiz de76a27c86
CI / Verify (push) Successful in 1m16s
CI / Build immutable images and deploy (push) Successful in 3m1s
feat : update
2026-09-06 15:26:08 +07:00

199 lines
7.6 KiB
TypeScript
Raw 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 Image from "next/image";
import { useRef, type PointerEvent } from "react";
import type {
CatalogAscensionGroup,
CatalogBaseStat,
} from "@/lib/catalog/lunaris";
import { cn } from "@/lib/utils";
type PosterMaterial = {
key: string;
amount: number;
name: string;
imageUrl: string | null;
};
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[] }>;
};
const statLabels: Record<string, string> = {
hp: "HP",
atk: "ATK",
def: "DEF",
em: "EM",
};
function statLabel(key: string): string {
return statLabels[key.toLowerCase()] ?? key;
}
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;
}
function clampOffset(value: number): number {
return Math.max(-50, Math.min(50, value));
}
export function MaterialPoster({
character,
characterArtUrl,
artworkOffsetX,
artworkOffsetY,
artworkScale,
topColors,
bottomColors,
interactive = false,
onArtworkMove,
className,
bleeding = [],
}: {
character: PosterCharacter;
characterArtUrl: string | null;
artworkOffsetX: number;
artworkOffsetY: number;
topColors: [string, string];
bottomColors: [string, string];
interactive?: boolean;
onArtworkMove?: (offset: { x: number; y: number }) => void;
className?: string;
artworkScale: number;
bleeding?: Array<{ id: string; imageUrl: string | null; amount: number; scale: number; blendMode: PosterBlendMode }>;
}) {
const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null);
const stats = character.baseStats.slice(0, 4);
const materials = character.ascension.flatMap((group) => group.items);
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: clampOffset(dragStart.current.offsetX + ((event.clientX - dragStart.current.x) / rect.width) * 100),
y: clampOffset(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 ? "ลากเพื่อเลื่อนภาพตัวละคร" : undefined}
>
{character.nameCardImageUrl ? (
<Image
src={character.nameCardImageUrl}
alt=""
fill
sizes="(min-width: 1024px) 800px, 100vw"
className="object-cover opacity-75"
priority={!interactive}
/>
) : null}
<div className="absolute inset-0 bg-black/55" />
<Image
src="/img/mat/fade.png"
alt=""
fill
sizes="(min-width: 1024px) 800px, 100vw"
className="pointer-events-none object-cover opacity-75"
priority={!interactive}
/>
{characterArtUrl ? (
<div
className="pointer-events-none absolute inset-0"
style={{ transform: `translate(${artworkOffsetX}%, ${artworkOffsetY}%) scale(${artworkScale / 100})` }}
>
<Image
src={characterArtUrl}
alt={character.name}
fill
sizes="(min-width: 1024px) 800px, 100vw"
className="object-contain object-center"
priority={!interactive}
/>
</div>
) : null}
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{bleeding.map((layer) => layer.imageUrl ? (
<Image
key={layer.id}
src={layer.imageUrl}
alt=""
fill
sizes="(min-width: 1024px) 800px, 100vw"
className="object-contain object-center"
style={{ opacity: Math.max(0, Math.min(100, layer.amount)) / 100, mixBlendMode: layer.blendMode, transform: `scale(${Math.max(25, Math.min(300, layer.scale)) / 100})` }}
/>
) : null)}
</div>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/10 via-transparent to-black/80" />
<div className="absolute inset-x-[4%] top-[5%]" style={{ fontFamily: "Kanit Poster", fontWeight: 800, fontStyle: "italic", textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}>
<h2 className="text-[7cqw] leading-none">BASE STATS</h2>
<div className="mt-[4%] flex w-[53%] flex-col gap-[1.5%]">
{Array.from({ length: 4 }, (_, index) => {
const stat = stats[index];
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>{stat ? statLabel(stat.key) : "—"}</span>
<span>{stat ? statValue(stat.key, stat.value) : "—"}</span>
</div>
);
})}
</div>
</div>
<div className="absolute inset-x-[4%] top-[51%]" style={{ fontFamily: "Kanit Poster", fontWeight: 800, fontStyle: "italic", textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}>
<h2 className="text-[7cqw] leading-none">MATERIALS</h2>
<p className="mt-[1%] text-[4.5cqw] leading-none">LV.90</p>
<div className="mt-[5%] grid grid-cols-8 gap-[1.2%]">
{materials.map((material, index) => (
<div key={`${material.key}-${index}`} className="aspect-[0.65] overflow-hidden rounded-[1.3cqw] bg-black/95 ring-1 ring-white/10">
<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={96} height={96} 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>×{material.amount.toLocaleString("en-US")}</span>
</div>
</div>
))}
</div>
</div>
</div>
);
}