feat(constellations) : add infographic image export
CI / Verify (push) Successful in 1m2s
CI / Build immutable images and deploy (push) Successful in 1m48s

This commit is contained in:
2026-09-03 15:07:22 +07:00 Unverified
parent c0ab204d06
commit fbc4d9d4b2
6 changed files with 219 additions and 6 deletions
+124
View File
@@ -0,0 +1,124 @@
import Image from "next/image";
import type { PublicGuide } from "@/lib/guides/queries";
import { constellationChartRows } from "@/lib/guides/export-image";
import { cn } from "@/lib/utils";
function percentage(value: number): string {
return `${value.toLocaleString(undefined, { maximumFractionDigits: 2 })}%`;
}
export function ConstellationImage({
guide,
group,
sectionNote,
}: {
guide: PublicGuide;
group: PublicGuide["constellationGroups"][number];
sectionNote: string | null | undefined;
}) {
const rows = constellationChartRows(group.rows);
const background = guide.character.nameCardImageUrl ?? guide.coverUrl;
const character = guide.character.splashImageUrl ?? guide.character.imageUrl;
const footer = group.note ?? sectionNote;
return (
<div className="relative isolate size-[1500px] overflow-hidden bg-[#070b20] font-heading text-white">
<Image
src={background}
alt=""
fill
loading="eager"
sizes="1500px"
className="object-cover"
/>
<div className="absolute inset-0 bg-[#050817]/65" />
<div className="absolute inset-x-20 top-7 z-10 flex items-start justify-center gap-5">
<Image
src="/icon/nav/Constellations.webp"
alt=""
width={112}
height={112}
loading="eager"
className="size-28 shrink-0 object-contain drop-shadow-[0_5px_4px_rgba(0,0,0,0.8)]"
/>
<div className="flex flex-col items-center text-center drop-shadow-[0_5px_4px_rgba(0,0,0,0.8)]">
<h2
className={cn(
"whitespace-nowrap leading-none font-bold",
guide.character.name.length > 16
? "text-[60px]"
: "text-[76px]",
)}
>
{guide.character.name} Constellations
</h2>
<p className="mt-3 text-[46px] leading-none font-bold text-[#f1badd]">
Team Damage
</p>
</div>
</div>
<div className="absolute top-[190px] right-0 bottom-0 z-10 w-[830px]">
<Image
src={character}
alt=""
fill
loading="eager"
sizes="830px"
className="object-contain object-right-bottom drop-shadow-[0_10px_8px_rgba(0,0,0,0.55)]"
/>
</div>
<div className="absolute top-[160px] bottom-[115px] left-[154px] z-20 w-1 bg-black/90" />
<div className="absolute inset-x-14 top-[205px] bottom-[145px] z-20 flex flex-col justify-between">
{rows.map((row) => (
<div
key={row.level}
className="grid grid-cols-[82px_1fr] items-center gap-4"
>
<div className="text-right text-[58px] leading-none font-bold drop-shadow-[0_5px_4px_rgba(0,0,0,0.9)]">
C{row.level}
</div>
<div className="flex h-[100px] items-center">
<div
className={cn(
"h-full shrink-0 rounded-r-xl",
row.level === 0
? "bg-[#bd91b1]"
: row.isGreat
? "bg-[#f0c76a]"
: "bg-[#ead0e1]",
)}
style={{ width: `${row.widthPercent * 8.5}px` }}
/>
<span
className={cn(
"ml-3 text-[42px] leading-none font-bold drop-shadow-[0_4px_3px_rgba(0,0,0,0.9)]",
row.isGreat ? "text-[#f0c76a]" : "text-[#f1badd]",
)}
>
{percentage(row.percent)}
</span>
</div>
</div>
))}
</div>
{footer ? (
<p className="absolute bottom-10 left-[154px] z-30 max-w-[1000px] truncate text-[28px] font-semibold drop-shadow-[0_4px_3px_rgba(0,0,0,0.9)]">
{footer}
</p>
) : null}
<Image
src="/icon/Watermark.png"
alt=""
width={280}
height={158}
loading="eager"
className="absolute right-4 bottom-0 z-30 h-auto w-[280px] object-contain drop-shadow-[0_4px_3px_rgba(0,0,0,0.8)]"
/>
</div>
);
}
+15 -3
View File
@@ -7,6 +7,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
const EXPORT_WIDTH = 1200;
@@ -17,9 +18,15 @@ function downloadFileName(value: string): string {
export function GuideImageExport({
children,
fileName,
width = EXPORT_WIDTH,
height,
captureClassName,
}: {
children: ReactNode;
fileName: string;
width?: number;
height?: number;
captureClassName?: string;
}) {
const captureRef = useRef<HTMLDivElement>(null);
const [capturing, setCapturing] = useState(false);
@@ -39,7 +46,8 @@ export function GuideImageExport({
const blob = await domToBlob(node, {
backgroundColor: getComputedStyle(node).backgroundColor,
scale: 1,
width: EXPORT_WIDTH,
width,
height,
});
if (cancelled) return;
@@ -62,7 +70,7 @@ export function GuideImageExport({
return () => {
cancelled = true;
};
}, [capturing, fileName]);
}, [capturing, fileName, height, width]);
return (
<>
@@ -88,7 +96,11 @@ export function GuideImageExport({
>
<div
ref={captureRef}
className="w-[1200px] bg-background p-10 text-foreground"
className={cn(
"bg-background p-10 text-foreground",
captureClassName,
)}
style={{ width, height }}
>
{children}
</div>
+15
View File
@@ -17,6 +17,7 @@ import { SiteHeader } from "@/components/public/site-header";
import { ScrollableNav } from "@/components/public/scrollable-nav";
import { TeamSearch } from "@/components/public/team-search";
import { GuideImageExport } from "@/components/public/guide-image-export";
import { ConstellationImage } from "@/components/public/constellation-image";
import { LiveRefresh } from "@/components/live-refresh";
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
import type { PublicGuide } from "@/lib/guides/queries";
@@ -900,6 +901,20 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
<div key={group.id} className="flex flex-col gap-4">
{groups.length > 1 ? <h2 className="font-heading text-2xl font-semibold">Condition {groupIndex + 1}</h2> : null}
{group.note ? <p className="whitespace-pre-wrap text-muted-foreground">{group.note}</p> : null}
<div>
<GuideImageExport
fileName={`${guide.name}-Constellations${groups.length > 1 ? `-Condition-${groupIndex + 1}` : ""}.png`}
width={1500}
height={1500}
captureClassName="p-0"
>
<ConstellationImage
guide={guide}
group={group}
sectionNote={note}
/>
</GuideImageExport>
</div>
<div className="grid gap-4 md:grid-cols-2">
{group.rows.map((row) => (
<Card key={row.level}>