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
+5 -15
View File
@@ -11,7 +11,7 @@ import { saveGuideOverview } from "@/app/admin/actions";
import { AutosaveStatus } from "@/components/admin/autosave-status";
import { MediaUploader } from "@/components/admin/media-uploader";
import { MaterialPoster, type PosterBlendMode } from "@/components/public/material-poster";
import { GuideImageExport } from "@/components/public/guide-image-export";
import { TakumiImageExport } from "@/components/public/takumi-image-export";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -458,26 +458,16 @@ export function GuideOverviewForm({
ลบภาพตัวละคร
</Button>
) : null}
<GuideImageExport
<TakumiImageExport
width={1600}
height={2000}
format="png"
disabled={!posterArt.url}
fileName={`${guide.name}-materials.png`}
allowWithoutNotes={false}
captureClassName="p-0"
>
<MaterialPoster
character={guide.character}
characterArtUrl={posterArt.url}
artworkOffsetX={posterArtOffset.x}
artworkOffsetY={posterArtOffset.y}
artworkScale={posterArtScale}
topColors={posterTopColors}
bottomColors={posterBottomColors}
bleeding={bleeding}
/>
</GuideImageExport>
preview
template={{ kind: "materials", data: { character: guide.character, characterArtUrl: posterArt.url, artworkOffsetX: posterArtOffset.x, artworkOffsetY: posterArtOffset.y, artworkScale: posterArtScale, topColors: posterTopColors, bottomColors: posterBottomColors, bleeding } }}
/>
</div>
<div className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-2">
+7 -81
View File
@@ -21,7 +21,6 @@ import {
import { CSS } from "@dnd-kit/utilities";
import {
CropIcon,
DownloadIcon,
GripVerticalIcon,
ImageIcon,
PlusIcon,
@@ -41,6 +40,7 @@ import {
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { TakumiImageExport } from "@/components/public/takumi-image-export";
import {
Card,
CardContent,
@@ -80,7 +80,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { elementIconFilter } from "@/lib/catalog/element";
import { rarityGradientClass } from "@/lib/catalog/rarity";
@@ -699,7 +698,6 @@ export function OverviewImageGenerator({
characters: Character[];
elementIconUrls: Record<string, string | null>;
}) {
const captureRef = useRef<HTMLDivElement>(null);
const [character, setCharacter] = useState<Character | null>(null);
const [artworkUrl, setArtworkUrl] = useState<string | null>(null);
const [artworkError, setArtworkError] = useState<string | null>(null);
@@ -735,7 +733,6 @@ export function OverviewImageGenerator({
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const [exporting, setExporting] = useState(false);
const [backgroundUrl, setBackgroundUrl] = useState<string | null>(null);
const [fadeColor, setFadeColor] = useState("#0E0200");
const [fadeTint, setFadeTint] = useState(100);
@@ -998,49 +995,6 @@ export function OverviewImageGenerator({
}
}
async function download() {
const node = captureRef.current;
if (
!node ||
!character ||
!artworkUrl ||
!normalizeHex(topColorStart) ||
!normalizeHex(topColorEnd) ||
!normalizeHex(bottomColorStart) ||
!normalizeHex(bottomColorEnd) ||
!normalizeHex(elementColorStart) ||
!normalizeHex(elementColorEnd) ||
!normalizeHex(fadeColor)
)
return;
setExporting(true);
try {
const { domToBlob, waitUntilLoad } = await import("modern-screenshot");
await Promise.all([document.fonts.ready, waitUntilLoad(node)]);
const blob = await domToBlob(node, {
backgroundColor: "#000000",
type: "image/png",
width: 1000,
height: 1000,
scale: 3,
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = overviewFileName(character.key, displayRole);
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
toast.success("ดาวน์โหลดภาพ Overview แล้ว");
} catch {
toast.error("ส่งออกภาพไม่สำเร็จ กรุณาลองอีกครั้ง");
} finally {
setExporting(false);
}
}
const ready = Boolean(
character &&
artworkUrl &&
@@ -1335,18 +1289,12 @@ export function OverviewImageGenerator({
</FieldGroup>
</CardContent>
<CardFooter className="flex flex-col gap-2">
<Button
className="w-full"
disabled={!ready || exporting}
onClick={() => void download()}
>
{exporting ? (
<Spinner data-icon="inline-start" />
) : (
<DownloadIcon data-icon="inline-start" />
)}
{exporting ? "กำลังส่งออก…" : "ดาวน์โหลด PNG 3000×3000"}
</Button>
<TakumiImageExport
width={3000} height={3000} allowWithoutNotes={false} preview disabled={!ready}
fileName={overviewFileName(character?.key ?? "overview", displayRole)}
buttonLabel="ดาวน์โหลด PNG 3000×3000"
template={{ kind: "overview", data: { composition, artworkUrl, character, role: displayRole, topColors: [topColorStart, topColorEnd], bottomColors: [bottomColorStart, bottomColorEnd], elementColors: [elementColorStart, elementColorEnd], elementGradientAngle, blendLayers, nameCardCrop, nameCardSize, elementIconUrls, disclaimerFontSize } }}
/>
</CardFooter>
</Card>
</div>
@@ -1469,28 +1417,6 @@ export function OverviewImageGenerator({
</CardContent>
</Card>
<div
ref={captureRef}
aria-hidden="true"
className="pointer-events-none fixed top-0 -left-[10000px] order-5 size-[1000px]"
>
<OverviewPoster
composition={composition}
artworkUrl={artworkUrl}
character={character}
role={displayRole}
topColors={[topColorStart, topColorEnd]}
bottomColors={[bottomColorStart, bottomColorEnd]}
elementColors={[elementColorStart, elementColorEnd]}
elementGradientAngle={elementGradientAngle}
blendLayers={blendLayers}
nameCardCrop={nameCardCrop}
nameCardSize={nameCardSize}
elementIconUrls={elementIconUrls}
disclaimerFontSize={disclaimerFontSize}
/>
</div>
<Dialog
open={Boolean(pendingCrop)}
onOpenChange={(open) => {
+8 -29
View File
@@ -1,9 +1,10 @@
"use client";
import { useState, type ReactNode } from "react";
import { useState } from "react";
import { DownloadIcon } from "lucide-react";
import { GuideImageExport } from "@/components/public/guide-image-export";
import { TakumiImageExport } from "@/components/public/takumi-image-export";
import type { PublicGuide } from "@/lib/guides/queries";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -27,13 +28,13 @@ const sectionOptions = [
type ArtifactExportSection = (typeof sectionOptions)[number][0];
export function ArtifactImageExport({
children,
guide,
fileName,
hasNote,
targetConditionLabels,
recommendedConditionLabels,
}: {
children: ReactNode;
guide: PublicGuide;
fileName: string;
hasNote: boolean;
targetConditionLabels: readonly string[];
@@ -47,15 +48,6 @@ export function ArtifactImageExport({
const [targetCondition, setTargetCondition] = useState("0");
const showNote = hasNote && includeNote;
const entireSection = sections.size === sectionOptions.length;
const hiddenSections = sectionOptions
.filter(([value]) => !sections.has(value))
.map(([value]) => `[data-artifact-export-section="${value}"]{display:none!important}`)
.join("");
const targetRule = `[data-target-condition]:not([data-target-condition="${targetCondition}"]){display:none!important}`;
const recommendedRule = `[data-recommended-condition]:not([data-recommended-condition="${recommendedCondition}"]){display:none!important}`;
const noteRule = showNote
? ""
: '[data-artifact-export-section="note"]{display:none!important}';
const onlySection = sections.size === 1 ? [...sections][0] : null;
const exportWidth = onlySection === "best-stats"
? 700
@@ -63,16 +55,6 @@ export function ArtifactImageExport({
? 800
: 1200;
const exportHeight = onlySection === "best-stats" ? 700 : undefined;
const hideEmptyRightColumn = !showNote && !sections.has("target-stats") && !sections.has("talents")
? ".artifact-export-right{display:none!important}"
: "";
const layoutRules = onlySection === "tierlist"
? ".artifact-export-grid{grid-template-columns:1fr!important}.artifact-export-tierlist{display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr));gap:1.25rem}"
: onlySection === "best-stats"
? ".artifact-export-root{height:100%!important}.artifact-export-grid{grid-template-columns:1fr!important}.artifact-export-left{display:flex!important;flex-direction:column!important}.artifact-export-main-stats,.artifact-export-substats{width:480px!important;max-width:100%!important;margin-inline:auto!important}.artifact-export-main-stats{order:1!important}.artifact-export-substats{order:2!important}.artifact-export-right{width:100%!important}.guide-export-watermark{top:auto!important;bottom:16px!important}"
: onlySection === "target-stats" || onlySection === "talents"
? ".artifact-export-grid{display:block!important}.artifact-export-right{width:560px!important;max-width:100%;margin-left:auto!important;margin-right:auto!important;justify-self:center!important}"
: "";
return (
<Dialog>
@@ -170,16 +152,13 @@ export function ArtifactImageExport({
</ToggleGroup>
</div>
) : null}
<GuideImageExport
<TakumiImageExport
fileName={fileName}
width={exportWidth}
height={exportHeight}
disabled={sections.size === 0}
captureClassName="p-0"
>
<style>{hiddenSections + targetRule + recommendedRule + noteRule + hideEmptyRightColumn + layoutRules}</style>
{children}
</GuideImageExport>
template={{ kind: "artifact", data: { guide, sections: [...sections], recommendedCondition: Number(recommendedCondition), targetCondition: Number(targetCondition), includeNote: showNote } }}
/>
</DialogContent>
</Dialog>
);
-91
View File
@@ -1,91 +0,0 @@
import Image from "next/image";
import { GuideExportBranding } from "@/components/public/guide-export-branding";
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("en-US", { maximumFractionDigits: 2 })}%`;
}
export function ConstellationImage({
guide,
group,
}: {
guide: PublicGuide;
group: PublicGuide["constellationGroups"][number];
}) {
const rows = constellationChartRows(group.rows);
const background = guide.character.splashImageUrl ?? guide.coverUrl;
const footer = group.note;
return (
<div className="relative isolate size-[1500px] overflow-hidden bg-[#070b20] font-heading text-white">
<GuideExportBranding backgroundUrl={background} variant="constellation" />
<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}’s Constellations`}
</h2>
<p className="mt-3 line-clamp-2 max-w-[1100px] whitespace-pre-line text-[42px] leading-tight font-bold text-[#f1badd]">
Team Damage
</p>
</div>
</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.isGreat ? "bg-warning" : "bg-white",
)}
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 data-export-note 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}
</div>
);
}
+1 -11
View File
@@ -62,17 +62,7 @@ export function GuideImageExport({
node.querySelectorAll<HTMLElement>("[data-export-note]").forEach((note) => {
note.style.display = "none";
});
// A note can be the only remaining content in the artifact right column.
const right = node.querySelector<HTMLElement>(".artifact-export-right");
if (right) {
const sections = right.querySelectorAll<HTMLElement>(
"[data-artifact-export-section]",
);
const hasVisibleSection = Array.from(sections).some(
(section) => getComputedStyle(section).display !== "none",
);
if (!hasVisibleSection) right.style.display = "none";
}
}
const { domToBlob, waitUntilLoad } = await import("modern-screenshot");
+10 -26
View File
@@ -31,7 +31,7 @@ import { WeaponChart } from "@/components/public/weapon-chart";
import { WeaponDisplayMode, WeaponDisplayModeProvider } from "@/components/public/weapon-display-mode";
import { ArtifactImageExport } from "@/components/public/artifact-image-export";
import { GuideExportBranding } from "@/components/public/guide-export-branding";
import { ConstellationImage } from "@/components/public/constellation-image";
import { TakumiImageExport } from "@/components/public/takumi-image-export";
import { ConditionSwitcher } from "@/components/public/condition-switcher";
import { MaterialPoster } from "@/components/public/material-poster";
import { RichTextContent } from "@/components/rich-text-content";
@@ -242,25 +242,15 @@ function GuideOverview({
bleeding={guide.posterBleeding}
linkMaterials
/>
<GuideImageExport
<TakumiImageExport
width={1600}
height={2000}
format="png"
fileName={`${guide.name}-materials.png`}
allowWithoutNotes={false}
captureClassName="p-0"
>
<MaterialPoster
character={guide.character}
characterArtUrl={guide.posterArtUrl}
artworkOffsetX={guide.posterArtOffsetX}
artworkOffsetY={guide.posterArtOffsetY}
artworkScale={guide.posterArtScale}
topColors={guide.posterTopColors}
bottomColors={guide.posterBottomColors}
bleeding={guide.posterBleeding}
/>
</GuideImageExport>
preview
template={{ kind: "materials", data: { character: guide.character, characterArtUrl: guide.posterArtUrl, artworkOffsetX: guide.posterArtOffsetX, artworkOffsetY: guide.posterArtOffsetY, artworkScale: guide.posterArtScale, topColors: guide.posterTopColors, bottomColors: guide.posterBottomColors, bleeding: guide.posterBleeding } }}
/>
</section>
) : null}
{youtubeVideos.length ? (
@@ -615,13 +605,12 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
guide={guide}
action={
<ArtifactImageExport
guide={guide}
fileName={`${guide.name}-Artifact.png`}
hasNote={Boolean(guide.artifactProfile?.note)}
recommendedConditionLabels={(guide.artifactProfile?.recommendedConditions ?? []).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}
targetConditionLabels={targetConditions.map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}
>
<ArtifactSectionContent guide={guide} exporting />
</ArtifactImageExport>
/>
}
/>
);
@@ -1156,17 +1145,12 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
{guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, groupIndex, groups) => (
<div key={group.id} className="flex flex-col gap-4">
<div>
<GuideImageExport
<TakumiImageExport
fileName={`${guide.name}-Constellations${groups.length > 1 ? `-${group.name || `Condition-${groupIndex + 1}`}` : ""}.png`}
width={1500}
height={1500}
captureClassName="p-0"
>
<ConstellationImage
guide={guide}
group={group}
/>
</GuideImageExport>
template={{ kind: "constellation", data: { guide, group } }}
/>
</div>
{groups.length > 1 ? <h2 className="font-heading text-2xl font-semibold">{group.name || `เงื่อนไข ${groupIndex + 1}`}</h2> : null}
{group.note ? <p className="whitespace-pre-wrap">{group.note}</p> : null}
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { DownloadIcon, EyeIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import type { ExportRequest, ExportTemplate } from "@/lib/image-export/types";
import { downloadImage, renderImage } from "@/lib/image-export/client";
export function TakumiImageExport({
template,
fileName,
width = 1200,
height,
format = "png",
disabled = false,
allowWithoutNotes = true,
preview = false,
buttonLabel = "ส่งออกภาพ",
}: {
template: ExportTemplate;
fileName: string;
width?: number;
height?: number;
format?: "png" | "webp";
disabled?: boolean;
allowWithoutNotes?: boolean;
preview?: boolean;
buttonLabel?: string;
}) {
const [mode, setMode] = useState<
"with-notes" | "without-notes" | "preview" | null
>(null);
const [image, setImage] = useState<{ url: string; key: string } | null>(null);
const mounted = useRef(false);
const previewUrl = useRef<string | null>(null);
const active = useRef(false);
const request = { ...template, width, height, format } as ExportRequest;
const key = preview ? JSON.stringify(request) : "";
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
if (previewUrl.current) URL.revokeObjectURL(previewUrl.current);
};
}, []);
async function generate(nextMode: NonNullable<typeof mode>) {
if (active.current) return;
active.current = true;
setMode(nextMode);
try {
const blob = await renderImage({
...request,
withoutNotes: nextMode === "without-notes",
});
if (!mounted.current) return;
if (nextMode === "preview") {
if (previewUrl.current) URL.revokeObjectURL(previewUrl.current);
previewUrl.current = URL.createObjectURL(blob);
setImage({ url: previewUrl.current, key });
} else {
const name =
nextMode === "without-notes"
? /\.[^.]+$/u.test(fileName)
? fileName.replace(/(\.[^.]+)$/u, "-without-notes$1")
: `${fileName}-without-notes`
: fileName;
downloadImage(blob, name);
}
} catch {
if (mounted.current) toast.error("ส่งออกภาพไม่สำเร็จ กรุณาลองอีกครั้ง");
} finally {
active.current = false;
if (mounted.current) setMode(null);
}
}
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
{(["with-notes", "without-notes"] as const)
.filter((value) => allowWithoutNotes || value === "with-notes")
.map((value) => (
<Button
key={value}
type="button"
variant="outline"
size="sm"
disabled={disabled || mode !== null}
onClick={() => void generate(value)}
>
{mode === value ? (
<Spinner data-icon="inline-start" />
) : (
<DownloadIcon data-icon="inline-start" />
)}
{mode === value
? "กำลังส่งออก…"
: value === "without-notes"
? "ส่งออกภาพโดยไม่รวมโน้ต"
: buttonLabel}
</Button>
))}
{preview ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled || mode !== null}
onClick={() => void generate("preview")}
>
{mode === "preview" ? (
<Spinner data-icon="inline-start" />
) : (
<EyeIcon data-icon="inline-start" />
)}
ดูตัวอย่างภาพ
</Button>
) : null}
</div>
{image ? (
<div className="flex flex-col gap-2">
{image.key !== key ? (
<p className="text-sm text-muted-foreground">
ตัวอย่างยังไม่รวมการเปลี่ยนแปลงล่าสุด กดดูตัวอย่างภาพอีกครั้ง
</p>
) : null}
{/* Encoded local preview must bypass Next.js image optimization. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image.url}
alt="ตัวอย่างภาพส่งออก"
className="h-auto max-h-[600px] max-w-full object-contain"
/>
</div>
) : null}
</div>
);
}
+2 -53
View File
@@ -6,7 +6,7 @@ import Link from "next/link";
import { ArrowUpRightIcon, SearchIcon, XIcon } from "lucide-react";
import { useMemo, useState, type ReactNode } from "react";
import { GuideImageExport } from "@/components/public/guide-image-export";
import { TakumiImageExport } from "@/components/public/takumi-image-export";
import { ScrollableNav } from "@/components/public/scrollable-nav";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -161,55 +161,6 @@ export function TeamArtifactIcon({
);
}
function TeamExportImage({ team }: { team: Team }) {
const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0);
const seconds = Number(team.rotationSeconds);
return (
<div className="flex min-h-337.5 w-270 flex-col bg-[#05060a] p-[48px_52px] font-heading text-[white]">
<header className="flex flex-col items-center text-center">
<h2 className="text-[64px] leading-tight font-bold">{team.title}</h2>
<p className="text-[32px] text-[#b9b9c2]">{`Rotation duration: ${seconds.toLocaleString("en-US", { maximumFractionDigits: 2 })} seconds`}</p>
</header>
<div className="mt-5 flex flex-col gap-4">
{team.members.map((member) => {
const damage = Number(member.damage);
const percent = total > 0 ? damage / total * 100 : 0;
return (
<div key={member.position} className="flex h-43.5 items-center gap-6 rounded-3xl p-3.5" style={{ backgroundColor: elementBackgroundColor(member.character.element) }}>
<div className={cn("relative size-35 shrink-0 overflow-hidden rounded-3xl border-[3px] border-white/20", rarityGradientClass(isCustomTravelerKey(member.character.key) ? 5 : member.character.rarity))}>
<Image src={member.character.imageUrl} alt="" fill loading="eager" sizes="140px" className="object-contain" />
</div>
<div className="grid w-44 shrink-0 grid-cols-2 gap-3">
{member.weapon ? <div className={cn("relative aspect-square min-w-0 scale-110 overflow-hidden rounded-2xl", rarityGradientClass(member.weapon.rarity))}><Image src={member.weapon.imageUrl} alt="" fill loading="eager" sizes="82px" className="object-contain p-1" /></div> : null}
<TeamArtifactIcon
first={member.artifact}
second={member.secondArtifact}
eager
sizes="82px"
className="min-w-0 rounded-2xl"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3.5">
<div className="flex items-center justify-between gap-4">
<span className="flex min-w-0 items-baseline gap-3 text-[36px] font-semibold">
<span className="truncate">{member.character.name}</span>
<span className="shrink-0 text-[28px] text-[#f0c95f]">C{member.constellation}R{member.refinement}</span>
</span>
<span className="shrink-0 text-[36px]">{damage.toLocaleString("en-US")}</span>
</div>
<div className="flex items-center gap-5"><div className="h-11 flex-1 overflow-hidden rounded-xl bg-[#272730]"><div className="h-full rounded-xl bg-linear-to-r from-[#b7791f] to-[#f0b72f]" style={{ width: `${Math.max(2, Math.min(100, percent))}%` }} /></div><span className="w-28 text-right text-[34px] font-bold">{percent.toFixed(1)}%</span></div>
</div>
</div>
);
})}
</div>
<div className="my-3 flex flex-1 items-center justify-center rounded-2xl bg-[#272730] p-4 text-center text-[28px] leading-[1.4]"><p className="whitespace-pre-wrap">{team.combo}</p></div>
<div className="flex shrink-0 justify-center border-t-2 border-[#34343d] pt-6 text-[64px] font-bold text-[#f0c95f]">DPS: {seconds > 0 ? (total / seconds).toLocaleString("en-US", { maximumFractionDigits: 0 }) : "0"}</div>
<div className="mt-5 flex shrink-0 justify-center text-[40px] text-[#74747f]">By YT Genshin Isn't Mobile Game</div>
</div>
);
}
function TeamCard({
team,
action,
@@ -463,9 +414,7 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) {
key={team.id}
team={team}
action={
<GuideImageExport fileName={`${team.title}.png`} width={1080} captureClassName="p-0" allowWithoutNotes={false}>
<TeamExportImage team={team} />
</GuideImageExport>
<TakumiImageExport fileName={`${team.title}.png`} width={1080} allowWithoutNotes={false} template={{ kind: "team", data: { team } }} />
}
/>
))}
+6 -97
View File
@@ -1,10 +1,10 @@
"use client";
import Image from "next/image";
import { DownloadIcon } from "lucide-react";
import { GuideExportBranding } from "@/components/public/guide-export-branding";
import { GuideImageExport } from "@/components/public/guide-image-export";
import { TakumiImageExport } from "@/components/public/takumi-image-export";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -17,90 +17,6 @@ import {
import type { PublicGuide } from "@/lib/guides/queries";
type WeaponGroup = PublicGuide["weaponGroups"][number];
type DamageMetric = "overallPercent" | "personalPercent";
function formattedPercent(value: string | null): string {
if (value === null) return "-%";
const numeric = Number(value);
if (numeric === 0) return "-%";
return `${numeric.toLocaleString("en-US", { maximumFractionDigits: 2 })}%`;
}
function WeaponComparisonImage({
guide,
group,
metric,
conditionLabel,
}: {
guide: PublicGuide;
group: WeaponGroup;
metric: DamageMetric;
conditionLabel: string;
}) {
const values = group.rows.map((row) => Number(row[metric]) || 0);
const maximum = Math.max(100, ...values);
return (
<div className="relative isolate w-300 overflow-hidden bg-[#070b20] px-14 pt-10 pb-12 font-heading text-white">
<GuideExportBranding backgroundUrl={guide.character.splashImageUrl ?? guide.coverUrl} />
<header className="relative z-10 flex flex-col items-center px-48 text-center drop-shadow-[0_4px_3px_rgba(0,0,0,0.9)]">
<h2 className="max-w-full text-5xl leading-tight font-bold text-balance">
{`${guide.character.name}’s Weapon Comparison`}
</h2>
<p className="mt-1 text-4xl font-bold text-white">
{metric === "overallPercent" ? "Overall Damage" : "Personal Damage"}
</p>
<p className="mt-2 text-2xl font-semibold">{conditionLabel}</p>
</header>
<div className="relative z-10 mt-9 flex flex-col gap-3">
{group.rows.map((row) => {
const numeric = Number(row[metric]) || 0;
const width = row[metric] === null
? 28
: numeric <= 100
? Math.max(28, Math.max(0, numeric) * 0.5)
: Math.min(90, 50 + ((numeric - 100) / Math.max(1, maximum - 100)) * 40);
return (
<div key={row.id} className="grid min-h-22 grid-cols-[76px_1fr] items-center gap-3">
<div className="relative size-18">
<Image
src={row.weapon.imageUrl}
alt=""
fill
loading="eager"
sizes="72px"
className="object-contain drop-shadow-[0_4px_3px_rgba(0,0,0,0.8)]"
/>
</div>
<div className="flex min-w-0 items-center">
<div
className="flex min-h-16 shrink-0 items-center rounded-r-xl bg-white px-5 text-[#15121a]"
style={{ width: `${width}%` }}
>
<span className="truncate text-[28px] font-bold">
{row.weapon.name} R{row.refinement}
</span>
</div>
<div className="ml-3 flex min-w-0 flex-1 flex-col items-start gap-1 text-white drop-shadow-[0_4px_3px_rgba(0,0,0,0.9)]">
<strong className="shrink-0 text-[30px]">{formattedPercent(row[metric])}</strong>
{row.note ? <span data-export-note className="min-w-0 text-xl leading-tight">{row.note}</span> : null}
</div>
</div>
</div>
);
})}
</div>
{group.note ? (
<p data-export-note className="relative z-10 mt-8 whitespace-pre-wrap text-center text-2xl font-semibold drop-shadow-[0_4px_3px_rgba(0,0,0,0.9)]">
{group.note}
</p>
) : null}
</div>
);
}
export function WeaponImageExport({
guide,
group,
@@ -134,20 +50,13 @@ export function WeaponImageExport({
</DialogHeader>
<div className="grid gap-2 sm:grid-cols-2">
{metricOptions.map(([metric, label]) => (
<GuideImageExport
<TakumiImageExport
key={metric}
fileName={`${baseName}-${label}.png`}
width={1200}
captureClassName="p-0"
buttonLabel={metric === "overallPercent" ? "ดาเมจทั้งทีม" : "ดาเมจส่วนตัว"}
>
<WeaponComparisonImage
guide={guide}
group={group}
metric={metric}
conditionLabel={conditionLabel}
/>
</GuideImageExport>
template={{ kind: "weapon", data: { guide, group, metric, conditionLabel } }}
/>
))}
</div>
</DialogContent>