feat : update
CI / Verify (push) Successful in 1m21s
CI / Build immutable images and deploy (push) Successful in 3m12s

This commit is contained in:
2026-09-06 14:47:11 +07:00 Unverified
parent 37d6d6703f
commit c3a7a44d02
19 changed files with 4218 additions and 38 deletions
+7 -2
View File
@@ -20,12 +20,16 @@ export function GuideImageExport({
fileName,
width = EXPORT_WIDTH,
height,
format = "png",
disabled = false,
captureClassName,
}: {
children: ReactNode;
fileName: string;
width?: number;
height?: number;
format?: "png" | "webp";
disabled?: boolean;
captureClassName?: string;
}) {
const captureRef = useRef<HTMLDivElement>(null);
@@ -45,6 +49,7 @@ export function GuideImageExport({
await Promise.all([document.fonts.ready, waitUntilLoad(node)]);
const blob = await domToBlob(node, {
backgroundColor: getComputedStyle(node).backgroundColor,
type: `image/${format}`,
scale: 1,
width,
height,
@@ -70,7 +75,7 @@ export function GuideImageExport({
return () => {
cancelled = true;
};
}, [capturing, fileName, height, width]);
}, [capturing, fileName, format, height, width]);
return (
<>
@@ -78,7 +83,7 @@ export function GuideImageExport({
type="button"
variant="outline"
size="sm"
disabled={capturing}
disabled={capturing || disabled}
onClick={() => setCapturing(true)}
>
{capturing ? (
+179
View File
@@ -0,0 +1,179 @@
"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;
};
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,
topColors,
bottomColors,
interactive = false,
onArtworkMove,
className,
}: {
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;
}) {
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}%)` }}
>
<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 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>
);
}
+45 -28
View File
@@ -29,6 +29,7 @@ import { TeamArtifactIcon, TeamSearch } from "@/components/public/team-search";
import { GuideImageExport } from "@/components/public/guide-image-export";
import { GuideExportBranding } from "@/components/public/guide-export-branding";
import { ConstellationImage } from "@/components/public/constellation-image";
import { MaterialPoster } from "@/components/public/material-poster";
import { RichTextContent } from "@/components/rich-text-content";
import { LiveRefresh } from "@/components/live-refresh";
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
@@ -267,34 +268,50 @@ function GuideOverview({
</Card>
)}
</div>
{youtubeVideo ? (
<section className="flex flex-col items-center gap-3">
<div className="aspect-video w-full overflow-hidden rounded-xl border bg-muted">
<iframe
src={youtubeVideo.embedUrl}
title={`วิดีโอไกด์ ${guide.name}`}
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
className="size-full"
/>
</div>
<Button
variant="outline"
render={
<a
href={youtubeVideo.watchUrl}
target="_blank"
rel="noopener noreferrer"
{guide.posterArtUrl || youtubeVideo ? (
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.75fr)]">
{guide.posterArtUrl ? (
<section className="order-1 lg:order-2">
<MaterialPoster
character={guide.character}
characterArtUrl={guide.posterArtUrl}
artworkOffsetX={guide.posterArtOffsetX}
artworkOffsetY={guide.posterArtOffsetY}
topColors={guide.posterTopColors}
bottomColors={guide.posterBottomColors}
/>
}
nativeButton={false}
>
ดูวิดีโอต้นฉบับบน YouTube
<ArrowUpRightIcon data-icon="inline-end" />
</Button>
</section>
</section>
) : null}
{youtubeVideo ? (
<section className="order-2 flex flex-col items-center gap-3 lg:order-1">
<div className="aspect-video w-full overflow-hidden rounded-xl border bg-muted">
<iframe
src={youtubeVideo.embedUrl}
title={`วิดีโอไกด์ ${guide.name}`}
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
className="size-full"
/>
</div>
<Button
variant="outline"
render={
<a
href={youtubeVideo.watchUrl}
target="_blank"
rel="noopener noreferrer"
/>
}
nativeButton={false}
>
ดูวิดีโอต้นฉบับบน YouTube
<ArrowUpRightIcon data-icon="inline-end" />
</Button>
</section>
) : null}
</div>
) : null}
</div>
);
@@ -520,7 +537,7 @@ function WeaponSectionContent({
: "text-sm sm:text-base",
)}
>
ดาเมททั้งทีม
ดาเมจทั้งทีม
</p>
</div>
) : null}