feat : add language export and some image gen optimaize

This commit is contained in:
2026-09-15 17:50:20 +07:00 Unverified
parent 5f1d2bccc9
commit 072310091d
9 changed files with 307 additions and 162 deletions
+83 -104
View File
@@ -80,6 +80,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Slider } from "@/components/ui/slider";
import { Spinner } from "@/components/ui/spinner";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { elementIconFilter } from "@/lib/catalog/element";
@@ -121,6 +122,7 @@ type Composition = {
fadeColor: string;
fadeTint: number;
auraUpOffset: number;
characterScale: number;
characterOffset: Position;
fadeOffset: Position;
};
@@ -140,10 +142,12 @@ const blendModeItems: Array<{ value: OverviewBlendMode; label: string }> = [
];
const DISCLAIMER = "ไม่สามารถเอาไปเปรียบเทียบกับการคำนวณของ TC ท่านอื่นๆได้\nเพราะแต่ละคนใช้สแตทในการคำนวณที่ไม่เหมือนกัน\nและตารางที่เห็นอาจมีการเปลี่ยนแปลงได้ในอนาคต";
const DEFAULT_FADE_OFFSET: Position = { x: 0, y: 2 };
function DraggablePosterLayer({
id,
offset,
scale = 1,
interactive,
className,
style,
@@ -151,6 +155,7 @@ function DraggablePosterLayer({
}: {
id: PosterLayer;
offset: Position;
scale?: number;
interactive: boolean;
className?: string;
style?: CSSProperties;
@@ -171,7 +176,7 @@ function DraggablePosterLayer({
className={className}
style={{
...style,
transform: `translate(${offset.x}%, ${offset.y}%)${temporaryTransform}`,
transform: `translate(${offset.x}%, ${offset.y}%)${temporaryTransform} scale(${scale})`,
}}
{...dragProps}
>
@@ -193,7 +198,6 @@ function OverviewPoster({
nameCardSize,
elementIconUrls,
composition,
disclaimerFontSize,
interactive = false,
activeLayer,
onLayerMove,
@@ -210,7 +214,6 @@ function OverviewPoster({
nameCardSize: { width: number; height: number } | null;
elementIconUrls: Record<string, string | null>;
composition: Composition;
disclaimerFontSize: number | null;
interactive?: boolean;
activeLayer?: PosterLayer;
onLayerMove?: (layer: PosterLayer, delta: Position) => void;
@@ -224,7 +227,6 @@ function OverviewPoster({
text: 5,
} as const;
const titleRef = useRef<HTMLParagraphElement>(null);
const disclaimerRef = useRef<HTMLParagraphElement>(null);
const posterRef = useRef<HTMLDivElement>(null);
const posterSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
@@ -243,29 +245,13 @@ function OverviewPoster({
useEffect(() => {
let cancelled = false;
const fit = () => {
if (cancelled || !titleRef.current || !disclaimerRef.current) return;
if (cancelled || !titleRef.current) return;
const title = titleRef.current;
const disclaimer = disclaimerRef.current;
const width = title.parentElement!.getBoundingClientRect().width;
title.style.fontSize = `${width / 10}px`;
if (title.scrollWidth > width) {
title.style.fontSize = `${((width / 10) * width) / title.scrollWidth}px`;
}
const textWidth = title.getBoundingClientRect().width;
const context = document.createElement("canvas").getContext("2d");
if (!context) return;
context.font = '100px "Genshin Overview"';
const longest = Math.max(
...DISCLAIMER.split("\n").map(
(line) => context.measureText(line).width,
),
);
disclaimer.style.width = "max-content";
disclaimer.style.textAlign = "center";
disclaimer.style.fontSize =
disclaimerFontSize === null
? `${Math.min(width * 0.024, (textWidth * 100) / longest)}px`
: `${disclaimerFontSize}cqw`;
};
const observer = new ResizeObserver(fit);
if (titleRef.current?.parentElement)
@@ -275,7 +261,7 @@ function OverviewPoster({
cancelled = true;
observer.disconnect();
};
}, [character?.name, disclaimerFontSize]);
}, [character?.name]);
return (
<DndContext sensors={posterSensors} onDragEnd={handlePosterDragEnd}>
@@ -326,6 +312,7 @@ function OverviewPoster({
<DraggablePosterLayer
id="character"
offset={composition.characterOffset}
scale={composition.characterScale}
interactive={interactive && activeLayer === "character"}
className={cn(
"absolute inset-0 cursor-grab active:cursor-grabbing",
@@ -340,7 +327,7 @@ function OverviewPoster({
unoptimized
loading="eager"
sizes="1024px"
className="object-cover"
className="object-contain"
/>
{[...blendLayers].reverse().map((layer) => (
<Image
@@ -351,7 +338,7 @@ function OverviewPoster({
unoptimized
loading="eager"
sizes="1024px"
className="object-cover"
className="object-contain"
style={{
mixBlendMode: layer.mode,
opacity: layer.amount / 100,
@@ -549,8 +536,7 @@ function OverviewPoster({
})}
</div>
<p
ref={disclaimerRef}
className="mt-[1.5cqw] whitespace-pre text-center text-[2.15cqw] leading-[1.15]"
className="mt-[1.5cqw] w-max whitespace-pre text-center text-[2.03167cqw] leading-[1.15]"
>
{DISCLAIMER}
</p>
@@ -668,14 +654,17 @@ function SortableBlendLayerRow({
<FieldLabel htmlFor={`overview-blend-amount-${layer.id}`}>
ความเข้ม {layer.amount}%
</FieldLabel>
<Input
<Slider
id={`overview-blend-amount-${layer.id}`}
type="range"
min={0}
max={100}
step={5}
value={layer.amount}
onChange={(event) => onUpdate({ amount: Number(event.target.value) })}
value={[layer.amount]}
onValueChange={(value) =>
onUpdate({
amount: typeof value === "number" ? value : value[0] ?? 0,
})
}
/>
</Field>
</div>
@@ -738,23 +727,20 @@ export function OverviewImageGenerator({
const [exporting, setExporting] = useState(false);
const [backgroundUrl, setBackgroundUrl] = useState<string | null>(null);
const [fadeColor, setFadeColor] = useState("#0E0200");
const [fadeTint, setFadeTint] = useState(100);
const [fadeTint, setFadeTint] = useState(0);
const [auraUpOffset, setAuraUpOffset] = useState(4);
const [characterScale, setCharacterScale] = useState(100);
const [characterOffset, setCharacterOffset] = useState<Position>({
x: 0,
y: 0,
});
const [fadeOffset, setFadeOffset] = useState<Position>({ x: 0, y: 0 });
const [activePosterLayer, setActivePosterLayer] =
useState<PosterLayer>("character");
const [disclaimerFontSize, setDisclaimerFontSize] = useState<number | null>(
null,
);
const [fadeOffset, setFadeOffset] = useState<Position>(DEFAULT_FADE_OFFSET);
const composition: Composition = {
backgroundUrl,
fadeColor,
fadeTint,
auraUpOffset,
characterScale: characterScale / 100,
characterOffset,
fadeOffset,
};
@@ -843,7 +829,7 @@ export function OverviewImageGenerator({
function resetPosterPositions() {
setCharacterOffset({ x: 0, y: 0 });
setFadeOffset({ x: 0, y: 0 });
setFadeOffset(DEFAULT_FADE_OFFSET);
}
useEffect(() => {
@@ -900,6 +886,10 @@ export function OverviewImageGenerator({
});
if (target === "artwork") {
detectArtworkTheme(image, image.naturalWidth, image.naturalHeight);
setCharacterScale(100);
setArtworkUrl(url);
setArtworkError(null);
return;
}
setArtworkError(null);
setCropArea(null);
@@ -1146,11 +1136,30 @@ export function OverviewImageGenerator({
onChange={(event) => selectArtwork(event)}
/>
<FieldDescription>
ทุกภาพจะเปิดหน้าต่างให้ครอป ซูม และจัดตำแหน่งก่อนใช้งาน
ใช้ภาพเต็มโดยไม่ครอป และสามารถลากเพื่อจัดตำแหน่งในตัวอย่างได้
</FieldDescription>
{artworkError ? <FieldError>{artworkError}</FieldError> : null}
</Field>
<Field data-disabled={!artworkUrl}>
<FieldLabel htmlFor="overview-artwork-zoom">
ซูมภาพตัวละคร {characterScale}%
</FieldLabel>
<Slider
id="overview-artwork-zoom"
min={25}
max={300}
step={1}
value={[characterScale]}
disabled={!artworkUrl}
onValueChange={(value) =>
setCharacterScale(
typeof value === "number" ? value : value[0] ?? 100,
)
}
/>
</Field>
<Field>
<FieldLabel htmlFor="overview-background">
อัปโหลดพื้นหลัง
@@ -1197,29 +1206,31 @@ export function OverviewImageGenerator({
<FieldLabel htmlFor="overview-fade-tint">
ความเข้มสี Fade {fadeTint}%
</FieldLabel>
<Input
<Slider
id="overview-fade-tint"
type="range"
min={0}
max={100}
step={1}
value={fadeTint}
onChange={(event) => setFadeTint(Number(event.target.value))}
value={[fadeTint]}
onValueChange={(value) =>
setFadeTint(typeof value === "number" ? value : value[0] ?? 0)
}
/>
</Field>
<Field>
<FieldLabel htmlFor="overview-aura-up">
เลื่อน Aura ด้านบนขึ้น {auraUpOffset}%
</FieldLabel>
<Input
<Slider
id="overview-aura-up"
type="range"
min={0}
max={7}
step={0.1}
value={auraUpOffset}
onChange={(event) =>
setAuraUpOffset(Number(event.target.value))
value={[auraUpOffset]}
onValueChange={(value) =>
setAuraUpOffset(
typeof value === "number" ? value : value[0] ?? 0,
)
}
/>
</Field>
@@ -1237,36 +1248,6 @@ export function OverviewImageGenerator({
เลือกเลเยอร์ใต้ตัวอย่าง แล้วลากในตัวอย่างเพื่อเลื่อนตำแหน่ง
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="overview-disclaimer-size">
ขนาด Disclaimer {disclaimerFontSize === null ? "ตามชื่ออัตโนมัติ" : `${disclaimerFontSize}cqw`}
</FieldLabel>
<div className="flex items-center gap-3">
<Input
id="overview-disclaimer-size"
type="range"
min={0.5}
max={5}
step={0.05}
value={disclaimerFontSize ?? 2.15}
onChange={(event) =>
setDisclaimerFontSize(Number(event.target.value))
}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={disclaimerFontSize === null}
onClick={() => setDisclaimerFontSize(null)}
>
อัตโนมัติ
</Button>
</div>
<FieldDescription>
ค่าเริ่มต้นจะปรับให้พอดีกับความกว้างชื่ออัตโนมัติ
</FieldDescription>
</Field>
<Field>
<FieldLabel>Role</FieldLabel>
<ToggleGroup
@@ -1373,32 +1354,30 @@ export function OverviewImageGenerator({
nameCardCrop={nameCardCrop}
nameCardSize={nameCardSize}
elementIconUrls={elementIconUrls}
disclaimerFontSize={disclaimerFontSize}
interactive
activeLayer={activePosterLayer}
activeLayer="character"
onLayerMove={movePosterLayer}
/>
</div>
<Field className="mt-4">
<FieldLabel>เลือกเลเยอร์ที่จะเลื่อน</FieldLabel>
<ToggleGroup
value={[activePosterLayer]}
onValueChange={(value) => {
const selected = value[0] as PosterLayer | undefined;
if (selected) setActivePosterLayer(selected);
}}
variant="outline"
className="w-full"
>
<ToggleGroupItem value="character" className="flex-1">
Char
</ToggleGroupItem>
<ToggleGroupItem value="fade" className="flex-1">
Fade
</ToggleGroupItem>
</ToggleGroup>
<FieldLabel htmlFor="overview-fade-y">
ตำแหน่ง Fade แนวตั้ง {fadeOffset.y}%
</FieldLabel>
<Slider
id="overview-fade-y"
min={-20}
max={20}
step={0.1}
value={[fadeOffset.y]}
onValueChange={(value) =>
setFadeOffset((current) => ({
...current,
y: typeof value === "number" ? value : value[0] ?? 2,
}))
}
/>
<FieldDescription>
เลือกเลเยอร์แล้วลากตรงบริเวณตัวอย่างเพื่อเลื่อนตำแหน่ง
ลากในตัวอย่างเพื่อเลื่อนตัวละคร และใช้แถบนี้เพื่อเลื่อน Fade
</FieldDescription>
</Field>
</CardContent>
@@ -1453,15 +1432,16 @@ export function OverviewImageGenerator({
<FieldLabel htmlFor="overview-element-gradient-angle">
มุมสีไล่ระดับธาตุ {elementGradientAngle}°
</FieldLabel>
<Input
<Slider
id="overview-element-gradient-angle"
type="range"
min={0}
max={360}
step={1}
value={elementGradientAngle}
onChange={(event) =>
setElementGradientAngle(Number(event.target.value))
value={[elementGradientAngle]}
onValueChange={(value) =>
setElementGradientAngle(
typeof value === "number" ? value : value[0] ?? 0,
)
}
/>
</Field>
@@ -1487,7 +1467,6 @@ export function OverviewImageGenerator({
nameCardCrop={nameCardCrop}
nameCardSize={nameCardSize}
elementIconUrls={elementIconUrls}
disclaimerFontSize={disclaimerFontSize}
/>
</div>
+2 -17
View File
@@ -42,10 +42,8 @@ export function ArtifactImageExport({
const [sections, setSections] = useState<Set<ArtifactExportSection>>(
() => new Set(sectionOptions.map(([value]) => value)),
);
const [includeNote, setIncludeNote] = useState(true);
const [recommendedCondition, setRecommendedCondition] = useState("0");
const [targetCondition, setTargetCondition] = useState("0");
const showNote = hasNote && includeNote;
const entireSection = sections.size === sectionOptions.length;
const hiddenSections = sectionOptions
.filter(([value]) => !sections.has(value))
@@ -53,9 +51,6 @@ export function ArtifactImageExport({
.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,7 +58,7 @@ export function ArtifactImageExport({
? 800
: 1200;
const exportHeight = onlySection === "best-stats" ? 700 : undefined;
const hideEmptyRightColumn = !showNote && !sections.has("target-stats") && !sections.has("talents")
const hideEmptyRightColumn = !hasNote && !sections.has("target-stats") && !sections.has("talents")
? ".artifact-export-right{display:none!important}"
: "";
const layoutRules = onlySection === "tierlist"
@@ -115,16 +110,6 @@ export function ArtifactImageExport({
<FieldLabel htmlFor={`artifact-export-${value}`}>{label}</FieldLabel>
</Field>
))}
{hasNote ? (
<Field orientation="horizontal">
<Checkbox
id="artifact-export-note"
checked={includeNote}
onCheckedChange={(checked) => setIncludeNote(checked === true)}
/>
<FieldLabel htmlFor="artifact-export-note">รวมโน้ต</FieldLabel>
</Field>
) : null}
</FieldGroup>
{sections.has("best-stats") && recommendedConditionLabels.length > 1 ? (
<div className="flex flex-col gap-2">
@@ -177,7 +162,7 @@ export function ArtifactImageExport({
disabled={sections.size === 0}
captureClassName="p-0"
>
<style>{hiddenSections + targetRule + recommendedRule + noteRule + hideEmptyRightColumn + layoutRules}</style>
<style>{hiddenSections + targetRule + recommendedRule + hideEmptyRightColumn + layoutRules}</style>
{children}
</GuideImageExport>
</DialogContent>
+2 -1
View File
@@ -1,6 +1,7 @@
import Image from "next/image";
import { GuideExportBranding } from "@/components/public/guide-export-branding";
import { ExportText } from "@/components/public/export-language";
import type { PublicGuide } from "@/lib/guides/queries";
import { constellationChartRows } from "@/lib/guides/export-image";
import { cn } from "@/lib/utils";
@@ -45,7 +46,7 @@ export function ConstellationImage({
{`${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
<ExportText th="ดาเมจทั้งทีม" en="Team Damage" />
</p>
</div>
</div>
+90
View File
@@ -0,0 +1,90 @@
"use client";
import { useSyncExternalStore } from "react";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
export type ExportLanguage = "th" | "en";
const STORAGE_KEY = "buzz-sheet:export-language";
const LANGUAGE_EVENT = "buzz-sheet:export-language-change";
let memoryLanguage: ExportLanguage = "th";
function storedLanguage(): ExportLanguage {
if (typeof window === "undefined") return "th";
try {
memoryLanguage = window.localStorage.getItem(STORAGE_KEY) === "en" ? "en" : "th";
return memoryLanguage;
} catch {
return memoryLanguage;
}
}
export function useExportLanguage() {
const language = useSyncExternalStore(
(onStoreChange) => {
window.addEventListener(LANGUAGE_EVENT, onStoreChange);
window.addEventListener("storage", onStoreChange);
return () => {
window.removeEventListener(LANGUAGE_EVENT, onStoreChange);
window.removeEventListener("storage", onStoreChange);
};
},
storedLanguage,
() => "th",
);
function setLanguage(next: ExportLanguage) {
memoryLanguage = next;
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Browser storage is optional; the current export still uses the selection.
} finally {
window.dispatchEvent(new CustomEvent(LANGUAGE_EVENT, { detail: next }));
}
}
return [language, setLanguage] as const;
}
export function ExportLanguageToggle({
language,
onChange,
}: {
language: ExportLanguage;
onChange: (language: ExportLanguage) => void;
}) {
return (
<ToggleGroup
aria-label="ภาษาในภาพส่งออก"
variant="outline"
size="sm"
value={[language]}
onValueChange={(values) => {
const next = values[0];
if (next === "th" || next === "en") onChange(next);
}}
>
<ToggleGroupItem
value="th"
>
ไทย
</ToggleGroupItem>
<ToggleGroupItem
value="en"
>
English
</ToggleGroupItem>
</ToggleGroup>
);
}
export function ExportText({ th, en }: { th: string; en: string }) {
return (
<>
<span data-export-lang="th">{th}</span>
<span data-export-lang="en">{en}</span>
</>
);
}
+39 -5
View File
@@ -6,6 +6,11 @@ import { DownloadIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
ExportLanguageToggle,
useExportLanguage,
type ExportLanguage,
} from "@/components/public/export-language";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
@@ -21,6 +26,21 @@ function downloadFileName(value: string): string {
return value.replace(/[\\/:*?"<>|]/gu, "-");
}
export function imageExportFileName(
fileName: string,
language: ExportLanguage,
withoutNotes: boolean,
localized = true,
): string {
const selectedFileName = withoutNotes ? withoutNotesFileName(fileName) : fileName;
const languageFileName = localized
? (/\.[^.]+$/u.test(selectedFileName)
? selectedFileName.replace(/(\.[^.]+)$/u, `-${language}$1`)
: `${selectedFileName}-${language}`)
: selectedFileName;
return downloadFileName(languageFileName);
}
export function GuideImageExport({
children,
fileName,
@@ -31,6 +51,9 @@ export function GuideImageExport({
allowWithoutNotes = true,
captureClassName,
buttonLabel = "ส่งออกภาพ",
language: controlledLanguage,
showLanguageSelector = true,
localizeFileName = true,
}: {
children: ReactNode;
fileName: string;
@@ -41,12 +64,17 @@ export function GuideImageExport({
allowWithoutNotes?: boolean;
captureClassName?: string;
buttonLabel?: string;
language?: ExportLanguage;
showLanguageSelector?: boolean;
localizeFileName?: boolean;
}) {
const captureRef = useRef<HTMLDivElement>(null);
const [captureMode, setCaptureMode] = useState<
"with-notes" | "without-notes" | null
>(null);
const capturing = captureMode !== null;
const [storedLanguage, setStoredLanguage] = useExportLanguage();
const language = controlledLanguage ?? storedLanguage;
useEffect(() => {
if (!capturing) return;
@@ -88,10 +116,11 @@ export function GuideImageExport({
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.download = downloadFileName(
captureMode === "without-notes"
? withoutNotesFileName(fileName)
: fileName,
link.download = imageExportFileName(
fileName,
language,
captureMode === "without-notes",
localizeFileName,
);
link.href = url;
document.body.append(link);
@@ -109,10 +138,13 @@ export function GuideImageExport({
return () => {
cancelled = true;
};
}, [capturing, captureMode, fileName, format, height, width]);
}, [capturing, captureMode, fileName, format, height, language, localizeFileName, width]);
return (
<>
{showLanguageSelector ? (
<ExportLanguageToggle language={language} onChange={setStoredLanguage} />
) : null}
<div className="flex flex-wrap gap-2">
{(["with-notes", "without-notes"] as const).filter((mode) => allowWithoutNotes || mode === "with-notes").map((mode) => (
<Button
@@ -144,12 +176,14 @@ export function GuideImageExport({
>
<div
ref={captureRef}
data-export-language={language}
className={cn(
"bg-background p-10 text-foreground",
captureClassName,
)}
style={{ width, height }}
>
<style>{'[data-export-language="th"] [data-export-lang="en"],[data-export-language="en"] [data-export-lang="th"]{display:none!important}'}</style>
{children}
</div>
</div>,
+40 -27
View File
@@ -26,6 +26,7 @@ import { SiteHeader } from "@/components/public/site-header";
import { ScrollableNav } from "@/components/public/scrollable-nav";
import { TeamArtifactIcon, TeamSearch } from "@/components/public/team-search";
import { GuideImageExport } from "@/components/public/guide-image-export";
import { ExportText } from "@/components/public/export-language";
import { WeaponImageExport } from "@/components/public/weapon-image-export";
import { WeaponChart } from "@/components/public/weapon-chart";
import { WeaponDisplayMode, WeaponDisplayModeProvider } from "@/components/public/weapon-display-mode";
@@ -248,6 +249,8 @@ function GuideOverview({
format="png"
fileName={`${guide.name}-materials.png`}
allowWithoutNotes={false}
showLanguageSelector={false}
localizeFileName={false}
captureClassName="p-0"
>
<MaterialPoster
@@ -358,6 +361,10 @@ function metric(value: string | null, locale?: string) {
: `${Number(value).toLocaleString(locale, { maximumFractionDigits: 2 })}%`;
}
function exportLabel(exporting: boolean, th: string, en: string): ReactNode {
return exporting ? <ExportText th={th} en={en} /> : th;
}
function lunarisItemUrl(
kind: "character" | "weapon" | "artifact",
key: string,
@@ -409,7 +416,7 @@ function WeaponSectionContent({
<header className="relative z-10 flex items-center justify-center text-center">
<div className="flex flex-col items-center gap-3">
<p className="text-sm font-medium text-primary">
{exporting ? "Recommended Weapons" : "อาวุธที่แนะนำ"}
อาวุธที่แนะนำ
</p>
<div className="flex items-center justify-center gap-3">
<Image
@@ -425,7 +432,7 @@ function WeaponSectionContent({
)}
/>
<h1 className="whitespace-nowrap font-heading text-4xl font-semibold">
{exporting ? "Weapons" : "อาวุธ"}
Weapons
</h1>
</div>
{note ? (
@@ -640,31 +647,31 @@ function ArtifactSectionContent({
const recommendedConditions = profile?.recommendedConditions ?? [];
const recommendedCards = recommendedConditions.map((condition, conditionIndex) => (
<div key={conditionIndex} data-artifact-export-section="best-stats" data-recommended-condition={conditionIndex} className="flex flex-col gap-5">
{exporting && recommendedConditions.length > 1 ? <p className="text-sm font-medium">{condition.name || `Condition ${conditionIndex + 1}`}</p> : null}
{exporting && recommendedConditions.length > 1 ? <p className="text-sm font-medium">{condition.name || <ExportText th={`เงื่อนไข ${conditionIndex + 1}`} en={`Condition ${conditionIndex + 1}`} />}</p> : null}
{condition.note ? <p data-export-note className="whitespace-pre-wrap text-sm text-muted-foreground">{condition.note}</p> : null}
{profile ? (
<Card data-artifact-export-section="best-stats" className="artifact-export-main-stats overflow-hidden pt-0">
<CardHeader className="bg-primary py-4 text-center text-primary-foreground">
<CardTitle className="text-2xl">{exporting ? "Best Main Stats" : "ออฟหลักที่ดีที่สุด"}</CardTitle>
<CardTitle className="text-2xl">{exportLabel(exporting, "ออฟหลักที่ดีที่สุด", "Best Main Stats")}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-3 p-0">
<ArtifactStat
iconSrc="/icon/Icon_Sands_of_Eon.webp"
label={exporting ? "Sands" : "นาฬิกา"}
label={exportLabel(exporting, "นาฬิกา", "Sands")}
values={condition.sands}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
<ArtifactStat
iconSrc="/icon/Icon_Goblet_of_Eonothem.webp"
label={exporting ? "Goblet" : "ถ้วย"}
label={exportLabel(exporting, "ถ้วย", "Goblet")}
values={condition.goblet}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
<ArtifactStat
iconSrc="/icon/Icon_Circlet_of_Logos.webp"
label={exporting ? "Circlet" : "หัว"}
label={exportLabel(exporting, "หัว", "Circlet")}
values={condition.circlet}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
@@ -675,7 +682,7 @@ function ArtifactSectionContent({
{condition.substats.length ? (
<Card data-artifact-export-section="best-stats" className="artifact-export-substats">
<CardContent className="flex flex-col items-center gap-2 py-5 text-center text-sm">
<strong>{exporting ? "Best Substats" : "ออฟรองที่ดีที่สุด"}</strong>
<strong>{exportLabel(exporting, "ออฟรองที่ดีที่สุด", "Best Substats")}</strong>
<div className="flex flex-col items-center gap-1">
{condition.substats.map((stat, index) => (
<ArtifactStatValue
@@ -710,7 +717,7 @@ function ArtifactSectionContent({
<header className="relative z-10 flex items-center justify-center text-center">
<div className="flex flex-col items-center gap-3">
<p className="text-sm font-medium text-primary">
{exporting ? "Recommended Artifacts & Stats" : "อาร์ติแฟกต์และสแตทที่แนะนำ"}
อาร์ติแฟกต์และสแตทที่แนะนำ
</p>
<div className="flex items-center justify-center gap-3">
<Image
@@ -726,7 +733,7 @@ function ArtifactSectionContent({
)}
/>
<h1 className="whitespace-nowrap font-heading text-4xl font-semibold">
{exporting ? "Artifacts & Stats" : "อาร์ติแฟกต์และค่าสถานะ"}
Artifacts & Stats
</h1>
</div>
{action}
@@ -751,9 +758,9 @@ function ArtifactSectionContent({
<CardHeader>
<CardTitle>
{group.name === "Late Game"
? (exporting ? "Late Game" : "เลทเกม")
? exportLabel(exporting, "เลทเกม", "Late Game")
: group.name === "Early Game"
? (exporting ? "Early Game" : "ต้นเกม")
? exportLabel(exporting, "ต้นเกม", "Early Game")
: group.name}
</CardTitle>
</CardHeader>
@@ -806,7 +813,7 @@ function ArtifactSectionContent({
exporting ? (
profile.targetConditions.filter((group) => group.rows.length).map((group, groupIndex) => (
<div key={groupIndex} data-artifact-export-section="target-stats" data-target-condition={groupIndex}>
<p className="mb-2 text-sm font-medium">{exporting ? "Recommended Minimum Stats" : "ค่าสถานะต่ำๆที่ควรจะปั้นไว้"}</p>
<p className="mb-2 text-sm font-medium">{exportLabel(exporting, "ค่าสถานะต่ำๆที่ควรจะปั้นไว้", "Recommended Minimum Stats")}</p>
{group.note ? <p data-export-note className="mb-2 whitespace-pre-wrap text-sm text-muted-foreground">{group.note}</p> : null}
<div className="flex flex-col gap-1">
{group.rows.map((target, index) => (
@@ -844,7 +851,7 @@ function ArtifactSectionContent({
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะเป้าหมาย" labels={profile.targetConditions.filter((group) => group.rows.length).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}>
{profile.targetConditions.filter((group) => group.rows.length).map((group, groupIndex) => (
<div key={groupIndex}>
<p className="mb-2 text-sm font-medium">{exporting ? "Recommended Minimum Stats" : "ค่าสถานะต่ำๆที่ควรจะปั้นไว้"}</p>
<p className="mb-2 text-sm font-medium">{exportLabel(exporting, "ค่าสถานะต่ำๆที่ควรจะปั้นไว้", "Recommended Minimum Stats")}</p>
{group.note ? <p data-export-note className="mb-2 whitespace-pre-wrap text-sm text-muted-foreground">{group.note}</p> : null}
<div className="flex flex-col gap-1">
{group.rows.map((target, index) => (
@@ -865,7 +872,7 @@ function ArtifactSectionContent({
{profile.talents.length ? (
<div data-artifact-export-section="talents">
<p className="mb-2 text-sm font-medium">
{exporting ? "Talent Priority" : "ลำดับการอัพพรสวรรค์"}
{exportLabel(exporting, "ลำดับการอัพพรสวรรค์", "Talent Priority")}
</p>
<div
className={cn(
@@ -915,7 +922,7 @@ function ArtifactSectionContent({
</div>
<Badge variant="secondary">
{talent.level
? (exporting ? `Level ${talent.level}+` : `เลเวล ${talent.level}+`)
? exportLabel(exporting, `เลเวล ${talent.level}+`, `Level ${talent.level}+`)
: "-"}
</Badge>
<p
@@ -933,10 +940,10 @@ function ArtifactSectionContent({
)}
>
{talent.type === "E"
? (exporting ? "Elemental Skill" : "สกิลธาตุ")
? exportLabel(exporting, "สกิลธาตุ", "Elemental Skill")
: talent.type === "Q"
? (exporting ? "Elemental Burst" : "ท่าไม้ตาย")
: (exporting ? "Normal Attack" : "ตีปกติ")}
? exportLabel(exporting, "ท่าไม้ตาย", "Elemental Burst")
: exportLabel(exporting, "ตีปกติ", "Normal Attack")}
</p>
</div>
{index < talents.length - 1 ? (
@@ -967,7 +974,7 @@ function ArtifactSectionContent({
) : null}
{profile.note ? (
<div data-export-note data-artifact-export-section="note" className="pt-3">
<p className="mb-2 text-sm font-medium">{exporting ? "Notes" : "โน็ต"}</p>
<p className="mb-2 text-sm font-medium">{exportLabel(exporting, "โน็ต", "Notes")}</p>
<p className="text-sm leading-6 text-muted-foreground">
{profile.note}
</p>
@@ -1016,8 +1023,14 @@ const englishStats: Record<string, string> = {
"CRIT Rate%/CRIT DMG%": "CRIT Rate / CRIT DMG",
};
function statLabel(value: string, exporting = false): string {
return (exporting ? englishStats : thaiStats)[value] ?? value;
function statLabel(value: string, exporting = false): ReactNode {
if (!exporting) return thaiStats[value] ?? value;
return (
<ExportText
th={thaiStats[value] ?? value}
en={englishStats[value] ?? value}
/>
);
}
function ArtifactStat({
@@ -1028,7 +1041,7 @@ function ArtifactStat({
elementIconUrls,
}: {
iconSrc: string;
label: string;
label: ReactNode;
values: string[];
exporting?: boolean;
elementIconUrls: Record<string, string>;
@@ -1143,7 +1156,7 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
className="size-14 shrink-0 object-contain"
/>
<h1 className="whitespace-nowrap font-heading text-4xl font-semibold">
กลุ่มดาว
Constellations
</h1>
</div>
{note ? (
@@ -1215,7 +1228,7 @@ function TeamSection({ guide }: { guide: PublicGuide }) {
<section className="flex flex-col gap-6">
<header className="text-center">
<p className="text-sm font-medium text-primary">คำนวณความเสียหาย</p>
<h1 className="font-heading text-4xl font-semibold">คำนวณทีม</h1>
<h1 className="font-heading text-4xl font-semibold">Team Calculation</h1>
</header>
<TeamSearch groups={guide.teamGroups} />
</section>
@@ -1261,7 +1274,7 @@ function ExtraSectionContent({
/>
) : null}
<header className="relative z-10 flex flex-col items-center gap-3 text-center">
<p className="text-sm font-medium text-primary">{exporting ? "Additional Guide" : "คู่มือเสริม"}</p>
<p className="text-sm font-medium text-primary">คู่มือเสริม</p>
<h1 className="font-heading text-4xl font-semibold">{extra.title}</h1>
{action}
</header>
@@ -1312,7 +1325,7 @@ function ExtraSectionContent({
) : null}
<div className="min-w-0">
<p className="truncate text-sm font-medium">{item.name}</p>
{!item.available ? <Badge variant="secondary">{exporting ? "Item unavailable" : "ไม่พบรายการ"}</Badge> : null}
{!item.available ? <Badge variant="secondary">{exportLabel(exporting, "ไม่พบรายการ", "Item unavailable")}</Badge> : null}
</div>
<ArrowUpRightIcon aria-hidden="true" className="ml-auto shrink-0" />
</a>
+7 -1
View File
@@ -7,6 +7,7 @@ import { ArrowUpRightIcon, SearchIcon, XIcon } from "lucide-react";
import { useMemo, useState, type ReactNode } from "react";
import { GuideImageExport } from "@/components/public/guide-image-export";
import { ExportText } from "@/components/public/export-language";
import { ScrollableNav } from "@/components/public/scrollable-nav";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -168,7 +169,12 @@ function TeamExportImage({ team }: { team: Team }) {
<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>
<p className="text-[32px] text-[#b9b9c2]">
<ExportText
th={`ระยะเวลาโรเทชั่น: ${seconds.toLocaleString("en-US", { maximumFractionDigits: 2 })} วินาที`}
en={`Rotation duration: ${seconds.toLocaleString("en-US", { maximumFractionDigits: 2 })} seconds`}
/>
</p>
</header>
<div className="mt-5 flex flex-col gap-4">
{team.members.map((member) => {
+22 -3
View File
@@ -2,8 +2,14 @@
import Image from "next/image";
import { DownloadIcon } from "lucide-react";
import type { ReactNode } from "react";
import { GuideExportBranding } from "@/components/public/guide-export-branding";
import {
ExportLanguageToggle,
ExportText,
useExportLanguage,
} from "@/components/public/export-language";
import { GuideImageExport } from "@/components/public/guide-image-export";
import { Button } from "@/components/ui/button";
import {
@@ -35,7 +41,7 @@ function WeaponComparisonImage({
guide: PublicGuide;
group: WeaponGroup;
metric: DamageMetric;
conditionLabel: string;
conditionLabel: ReactNode;
}) {
const values = group.rows.map((row) => Number(row[metric]) || 0);
const maximum = Math.max(100, ...values);
@@ -48,7 +54,11 @@ function WeaponComparisonImage({
{`${guide.character.name}’s Weapon Comparison`}
</h2>
<p className="mt-1 text-4xl font-bold text-white">
{metric === "overallPercent" ? "Overall Damage" : "Personal Damage"}
{metric === "overallPercent" ? (
<ExportText th="ดาเมจทั้งทีม" en="Overall Damage" />
) : (
<ExportText th="ดาเมจส่วนตัว" en="Personal Damage" />
)}
</p>
<p className="mt-2 text-2xl font-semibold">{conditionLabel}</p>
</header>
@@ -110,6 +120,7 @@ export function WeaponImageExport({
group: WeaponGroup;
conditionIndex: number;
}) {
const [language, setLanguage] = useExportLanguage();
const conditionLabel = group.name || `Condition ${conditionIndex + 1}`;
const baseName = `${guide.name}-Weapon-${conditionLabel}`;
const metricOptions = ([
@@ -132,6 +143,7 @@ export function WeaponImageExport({
เลือกค่าที่ต้องการใช้สร้างภาพเปรียบเทียบอาวุธ
</DialogDescription>
</DialogHeader>
<ExportLanguageToggle language={language} onChange={setLanguage} />
<div className="grid gap-2 sm:grid-cols-2">
{metricOptions.map(([metric, label]) => (
<GuideImageExport
@@ -139,13 +151,20 @@ export function WeaponImageExport({
fileName={`${baseName}-${label}.png`}
width={1200}
captureClassName="p-0"
language={language}
showLanguageSelector={false}
buttonLabel={metric === "overallPercent" ? "ดาเมจทั้งทีม" : "ดาเมจส่วนตัว"}
>
<WeaponComparisonImage
guide={guide}
group={group}
metric={metric}
conditionLabel={conditionLabel}
conditionLabel={group.name ? conditionLabel : (
<ExportText
th={`เงื่อนไข ${conditionIndex + 1}`}
en={`Condition ${conditionIndex + 1}`}
/>
)}
/>
</GuideImageExport>
))}
+22 -4
View File
@@ -2,7 +2,10 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { GuideImageExport } from "@/components/public/guide-image-export";
import {
GuideImageExport,
imageExportFileName,
} from "@/components/public/guide-image-export";
import { MaterialPoster } from "@/components/public/material-poster";
import { ConstellationImage } from "@/components/public/constellation-image";
import type { PublicGuide } from "@/lib/guides/queries";
@@ -12,14 +15,16 @@ describe("image export controls", () => {
const html = renderToStaticMarkup(<GuideImageExport fileName="guide.png">No notes</GuideImageExport>);
expect(html).toContain("ส่งออกภาพ");
expect(html).toContain("ส่งออกภาพโดยไม่รวมโน้ต");
expect(html.match(/<button\b/gu)).toHaveLength(2);
expect(html).toContain("ไทย");
expect(html).toContain("English");
expect(html.match(/<button\b/gu)).toHaveLength(4);
});
it("shows only the Thai export button when no-note export is disabled", () => {
it("shows only one export action when no-note export is disabled", () => {
const html = renderToStaticMarkup(<GuideImageExport fileName="guide.png" allowWithoutNotes={false}>Content</GuideImageExport>);
expect(html).toContain("ส่งออกภาพ");
expect(html).not.toContain("ส่งออกภาพโดยไม่รวมโน้ต");
expect(html.match(/<button\b/gu)).toHaveLength(1);
expect(html.match(/<button\b/gu)).toHaveLength(3);
});
it("disables both modes when an export selection is empty", () => {
@@ -32,12 +37,25 @@ describe("image export controls", () => {
const group = { rows: [], note } as unknown as PublicGuide["constellationGroups"][number];
const html = renderToStaticMarkup(createElement(ConstellationImage, { guide, group }));
expect(html).toContain("Raiden’s Constellations");
expect(html).not.toContain("กลุ่มดาวของ Raiden");
expect(html).toContain("Team Damage");
expect(html).toContain("ดาเมจทั้งทีม");
expect(html).toContain('data-export-note="true"');
expect(html).toContain(note);
});
});
describe("image export filenames", () => {
it("adds the selected language after the note variant", () => {
expect(imageExportFileName("Raiden.png", "th", false)).toBe("Raiden-th.png");
expect(imageExportFileName("Raiden.png", "en", true)).toBe("Raiden-without-notes-en.png");
});
it("can preserve English-only export filenames", () => {
expect(imageExportFileName("Raiden-materials.png", "th", false, false)).toBe("Raiden-materials.png");
});
});
describe("material poster text", () => {
const props = {