feat : dreamgineer ask me try to use this and now im ran out of token sad [no ci]

This commit is contained in:
2026-09-14 18:54:32 +07:00 Unverified
parent dc7f61aa6e
commit 4a6bfd599a
34 changed files with 3222 additions and 332 deletions
+307
View File
@@ -0,0 +1,307 @@
import { Image, Link } from "./primitives";
import type {
CatalogAscensionGroup,
CatalogBaseStat,
} from "@/lib/catalog/lunaris";
import {
mergePosterMaterials,
type PosterMaterial,
} from "@/lib/material-poster";
import { accentGradient } from "@/lib/overview-image";
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,
className,
bleeding = [],
}: {
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 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%)`;
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 }}
role={interactive && characterArtUrl ? "application" : undefined}
aria-label={
interactive && characterArtUrl ? "ลากเพื่อเลื่อนภาพตัวละคร" : 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)" }}
>
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) => {
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={`เปิดข้อมูล ${material.name} บน Lunaris`}
>
{materialCard}
</Link>
) : (
<div key={`${material.key}-${index}`} className={className}>
{materialCard}
</div>
);
})}
</div>
</div>
</div>
);
}