feat : dreamgineer ask me try to use this and now im ran out of token sad [no ci]
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 } }} />
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user