fix : overhual unnatural text
CI / Verify (push) Failing after 1m19s
CI / Build immutable images and deploy (push) Has been skipped

This commit is contained in:
2026-09-14 18:27:20 +07:00 Unverified
parent f214d330f0
commit dc7f61aa6e
12 changed files with 181 additions and 315 deletions
+1
View File
@@ -464,6 +464,7 @@ export function GuideOverviewForm({
format="png"
disabled={!posterArt.url}
fileName={`${guide.name}-materials.png`}
allowWithoutNotes={false}
captureClassName="p-0"
>
<MaterialPoster
+6 -17
View File
@@ -98,7 +98,6 @@ import {
weaponTypeIconPath,
type OverviewRole,
} from "@/lib/overview-image";
import { exportText, withoutNotesFileName } from "@/lib/guides/export-text";
import { cn } from "@/lib/utils";
type Character = {
@@ -140,7 +139,7 @@ const blendModeItems: Array<{ value: OverviewBlendMode; label: string }> = [
...overviewBlendModes,
];
const DISCLAIMER = exportText("disclaimer");
const DISCLAIMER = "ไม่สามารถเอาไปเปรียบเทียบกับการคำนวณของ TC ท่านอื่นๆได้\nเพราะแต่ละคนใช้สแตทในการคำนวณที่ไม่เหมือนกัน\nและตารางที่เห็นอาจมีการเปลี่ยนแปลงได้ในอนาคต";
function DraggablePosterLayer({
id,
@@ -999,7 +998,7 @@ export function OverviewImageGenerator({
}
}
async function download(includeNotes = true) {
async function download() {
const node = captureRef.current;
if (
!node ||
@@ -1029,15 +1028,14 @@ export function OverviewImageGenerator({
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
const fileName = overviewFileName(character.key, displayRole);
link.download = includeNotes ? fileName : withoutNotesFileName(fileName);
link.download = overviewFileName(character.key, displayRole);
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
toast.success(exportText("overviewDownloaded"));
toast.success("ดาวน์โหลดภาพ Overview แล้ว");
} catch {
toast.error(exportText("exportError"));
toast.error("ส่งออกภาพไม่สำเร็จ กรุณาลองอีกครั้ง");
} finally {
setExporting(false);
}
@@ -1347,16 +1345,7 @@ export function OverviewImageGenerator({
) : (
<DownloadIcon data-icon="inline-start" />
)}
{exporting ? exportText("exporting") : exportText("downloadPng", { width: 3000, height: 3000 })}
</Button>
<Button
variant="outline"
className="w-full"
disabled={!ready || exporting}
onClick={() => void download(false)}
>
<DownloadIcon data-icon="inline-start" />
{exportText("withoutNote")}
{exporting ? "กำลังส่งออก…" : "ดาวน์โหลด PNG 3000×3000"}
</Button>
</CardFooter>
</Card>
+12 -13
View File
@@ -3,7 +3,6 @@
import { useState, type ReactNode } from "react";
import { DownloadIcon } from "lucide-react";
import { exportText } from "@/lib/guides/export-text";
import { GuideImageExport } from "@/components/public/guide-image-export";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -19,10 +18,10 @@ import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
const sectionOptions = [
["tierlist", "tierlist"],
["best-stats", "bestStats"],
["target-stats", "targetStats"],
["talents", "talents"],
["tierlist", "อันดับอาร์ติแฟกต์"],
["best-stats", "ออฟหลักและออฟรองที่ดีที่สุด"],
["target-stats", "ค่าสถานะเป้าหมาย"],
["talents", "ลำดับการอัพพรสวรรค์"],
] as const;
type ArtifactExportSection = (typeof sectionOptions)[number][0];
@@ -79,13 +78,13 @@ export function ArtifactImageExport({
<Dialog>
<DialogTrigger render={<Button type="button" variant="outline" size="sm" />}>
<DownloadIcon data-icon="inline-start" />
{exportText("exportImage")}
ส่งออกภาพ
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{exportText("sectionTitle")}</DialogTitle>
<DialogTitle>เลือกข้อมูลสำหรับส่งออก</DialogTitle>
<DialogDescription>
{exportText("sectionDescription")}
เลือกได้หลายส่วน หรือเลือกทั้งหมดเพื่อส่งออกทุกส่วน
</DialogDescription>
</DialogHeader>
<FieldGroup>
@@ -97,7 +96,7 @@ export function ArtifactImageExport({
checked ? new Set(sectionOptions.map(([value]) => value)) : new Set(),
)}
/>
<FieldLabel htmlFor="artifact-export-entire">{exportText("allSections")}</FieldLabel>
<FieldLabel htmlFor="artifact-export-entire">ทั้งหมด</FieldLabel>
</Field>
{sectionOptions.map(([value, label]) => (
<Field key={value} orientation="horizontal">
@@ -113,7 +112,7 @@ export function ArtifactImageExport({
});
}}
/>
<FieldLabel htmlFor={`artifact-export-${value}`}>{exportText(label)}</FieldLabel>
<FieldLabel htmlFor={`artifact-export-${value}`}>{label}</FieldLabel>
</Field>
))}
{hasNote ? (
@@ -123,13 +122,13 @@ export function ArtifactImageExport({
checked={includeNote}
onCheckedChange={(checked) => setIncludeNote(checked === true)}
/>
<FieldLabel htmlFor="artifact-export-note">{exportText("addNote")}</FieldLabel>
<FieldLabel htmlFor="artifact-export-note">รวมโน้ต</FieldLabel>
</Field>
) : null}
</FieldGroup>
{sections.has("best-stats") && recommendedConditionLabels.length > 1 ? (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">{exportText("recommendedCondition")}</p>
<p className="text-sm font-medium">เงื่อนไขค่าสถานะแนะนำ</p>
<ToggleGroup
variant="outline"
value={[recommendedCondition]}
@@ -151,7 +150,7 @@ export function ArtifactImageExport({
) : null}
{sections.has("target-stats") && targetConditionLabels.length > 1 ? (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">{exportText("targetCondition")}</p>
<p className="text-sm font-medium">เงื่อนไขค่าสถานะเป้าหมาย</p>
<ToggleGroup
variant="outline"
value={[targetCondition]}
+2 -3
View File
@@ -3,7 +3,6 @@ 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 { exportText } from "@/lib/guides/export-text";
import { cn } from "@/lib/utils";
function percentage(value: number): string {
@@ -43,10 +42,10 @@ export function ConstellationImage({
: "text-[76px]",
)}
>
{exportText("constellationTitle", { name: guide.character.name })}
{`${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]">
{exportText("teamDamage")}
Team Damage
</p>
</div>
</div>
+13 -6
View File
@@ -7,11 +7,16 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { exportText, withoutNotesFileName } from "@/lib/guides/export-text";
import { cn } from "@/lib/utils";
const EXPORT_WIDTH = 1200;
function withoutNotesFileName(fileName: string): string {
return /\.[^.]+$/u.test(fileName)
? fileName.replace(/(\.[^.]+)$/u, "-without-notes$1")
: `${fileName}-without-notes`;
}
function downloadFileName(value: string): string {
return value.replace(/[\\/:*?"<>|]/gu, "-");
}
@@ -23,8 +28,9 @@ export function GuideImageExport({
height,
format = "png",
disabled = false,
allowWithoutNotes = true,
captureClassName,
buttonLabel = exportText("exportImage"),
buttonLabel = "ส่งออกภาพ",
}: {
children: ReactNode;
fileName: string;
@@ -32,6 +38,7 @@ export function GuideImageExport({
height?: number;
format?: "png" | "webp";
disabled?: boolean;
allowWithoutNotes?: boolean;
captureClassName?: string;
buttonLabel?: string;
}) {
@@ -92,7 +99,7 @@ export function GuideImageExport({
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
} catch {
if (!cancelled) toast.error(exportText("exportError"));
if (!cancelled) toast.error("ส่งออกภาพไม่สำเร็จ กรุณาลองอีกครั้ง");
} finally {
if (!cancelled) setCaptureMode(null);
}
@@ -107,7 +114,7 @@ export function GuideImageExport({
return (
<>
<div className="flex flex-wrap gap-2">
{(["with-notes", "without-notes"] as const).map((mode) => (
{(["with-notes", "without-notes"] as const).filter((mode) => allowWithoutNotes || mode === "with-notes").map((mode) => (
<Button
key={mode}
type="button"
@@ -122,9 +129,9 @@ export function GuideImageExport({
<DownloadIcon data-icon="inline-start" />
)}
{captureMode === mode
? exportText("exporting")
? "กำลังส่งออก…"
: mode === "without-notes"
? exportText("withoutNote")
? "ส่งออกภาพโดยไม่รวมโน้ต"
: buttonLabel}
</Button>
))}
+2 -5
View File
@@ -10,7 +10,6 @@ import type {
} from "@/lib/catalog/lunaris";
import { mergePosterMaterials, type PosterMaterial } from "@/lib/material-poster";
import { accentGradient } from "@/lib/overview-image";
import { exportText, type Locale } from "@/lib/guides/export-text";
import { cn } from "@/lib/utils";
export type PosterBlendMode = "normal" | "multiply" | "screen" | "overlay" | "darken" | "lighten" | "color-dodge" | "color-burn" | "hard-light" | "soft-light" | "difference" | "exclusion" | "hue" | "saturation" | "color" | "luminosity";
@@ -66,9 +65,7 @@ export function MaterialPoster({
onArtworkMove,
className,
bleeding = [],
locale = "en",
}: {
locale?: Locale;
character: PosterCharacter;
characterArtUrl: string | null;
artworkOffsetX: number;
@@ -219,8 +216,8 @@ export function MaterialPoster({
</div>
<div className="absolute inset-x-[4%] top-[49%]" style={{ fontFamily: "Kanit Poster", fontWeight: 800, fontStyle: "italic" }}>
<h2 className="text-[7cqw] leading-none" style={{ textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}>{exportText("materials", {}, locale)}</h2>
<p className="mt-[1%] text-[4.5cqw] leading-none">{exportText("materialLevel", { level: 90 }, locale)}</p>
<h2 className="text-[7cqw] leading-none" style={{ textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}>MATERIALS</h2>
<p className="mt-[1%] text-[4.5cqw] leading-none">LV.90</p>
<div className="mt-[5%] grid grid-cols-8 gap-[1.2%]">
{materials.map((material, index) => {
const materialCard = (
+72 -34
View File
@@ -54,7 +54,6 @@ import {
} from "@/lib/guides/talents";
import { parseYouTubeUrl } from "@/lib/guides/youtube";
import { normalizeExtraRichText } from "@/lib/rich-text";
import { exportText, exportStatLabel, type Locale } from "@/lib/guides/export-text";
import { cn } from "@/lib/utils";
function hasSection(guide: PublicGuide, slug: string): boolean {
@@ -248,6 +247,7 @@ function GuideOverview({
height={2000}
format="png"
fileName={`${guide.name}-materials.png`}
allowWithoutNotes={false}
captureClassName="p-0"
>
<MaterialPoster
@@ -409,7 +409,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 +425,7 @@ function WeaponSectionContent({
)}
/>
<h1 className="whitespace-nowrap font-heading text-4xl font-semibold">
Weapons
{exporting ? "Weapons" : "อาวุธ"}
</h1>
</div>
{note ? (
@@ -438,14 +438,14 @@ function WeaponSectionContent({
<WeaponDisplayModeProvider>
<ConditionSwitcher
label="เลือกเงื่อนไขอาวุธ"
labels={weaponGroups.map((group, index) => group.name || `Condition ${index + 1}`)}
labels={weaponGroups.map((group, index) => group.name || (exporting ? `Condition ${index + 1}` : `เงื่อนไข ${index + 1}`))}
>
{weaponGroups.map((group, groupIndex, groups) => (
<div key={group.id} className="relative z-10 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
{groups.length > 1 || conditionNumber ? (
<h2 className="font-heading text-2xl font-semibold">
{group.name || `Condition ${conditionNumber ?? groupIndex + 1}`}
{group.name || (exporting ? `Condition ${conditionNumber ?? groupIndex + 1}` : `เงื่อนไข ${conditionNumber ?? groupIndex + 1}`)}
</h2>
) : <span />}
{showExportActions ? (
@@ -617,8 +617,8 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
<ArtifactImageExport
fileName={`${guide.name}-Artifact.png`}
hasNote={Boolean(guide.artifactProfile?.note)}
recommendedConditionLabels={(guide.artifactProfile?.recommendedConditions ?? []).map((group, index) => group.name || `Condition ${index + 1}`)}
targetConditionLabels={targetConditions.map((group, index) => group.name || `Condition ${index + 1}`)}
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>
@@ -640,31 +640,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 || exportText("condition", { index: conditionIndex + 1 })}</p> : null}
{exporting && recommendedConditions.length > 1 ? <p className="text-sm font-medium">{condition.name || `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">{exportText("mainStats", {}, exporting ? "en" : "th")}</CardTitle>
<CardTitle className="text-2xl">{exporting ? "Best Main Stats" : "ออฟหลักที่ดีที่สุด"}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-3 p-0">
<ArtifactStat
iconSrc="/icon/Icon_Sands_of_Eon.webp"
label={exportText("sands", {}, exporting ? "en" : "th")}
label={exporting ? "Sands" : "นาฬิกา"}
values={condition.sands}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
<ArtifactStat
iconSrc="/icon/Icon_Goblet_of_Eonothem.webp"
label={exportText("goblet", {}, exporting ? "en" : "th")}
label={exporting ? "Goblet" : "ถ้วย"}
values={condition.goblet}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
<ArtifactStat
iconSrc="/icon/Icon_Circlet_of_Logos.webp"
label={exportText("circlet", {}, exporting ? "en" : "th")}
label={exporting ? "Circlet" : "หัว"}
values={condition.circlet}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
@@ -675,7 +675,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>{exportText("substats", {}, exporting ? "en" : "th")}</strong>
<strong>{exporting ? "Best Substats" : "ออฟรองที่ดีที่สุด"}</strong>
<div className="flex flex-col items-center gap-1">
{condition.substats.map((stat, index) => (
<ArtifactStatValue
@@ -710,7 +710,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">
{exportText("artifactRecommendation", {}, exporting ? "en" : "th")}
{exporting ? "Recommended Artifacts & Stats" : "อาร์ติแฟกต์และสแตทที่แนะนำ"}
</p>
<div className="flex items-center justify-center gap-3">
<Image
@@ -726,7 +726,7 @@ function ArtifactSectionContent({
)}
/>
<h1 className="whitespace-nowrap font-heading text-4xl font-semibold">
{exportText("artifactTitle", {}, exporting ? "en" : "th")}
{exporting ? "Artifacts & Stats" : "อาร์ติแฟกต์และค่าสถานะ"}
</h1>
</div>
{action}
@@ -741,7 +741,7 @@ function ArtifactSectionContent({
{" "}
<div className="artifact-export-left flex flex-col gap-5">
{exporting ? recommendedCards : (
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะแนะนำ" labels={recommendedConditions.map((condition, index) => condition.name || `Condition ${index + 1}`)}>
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะแนะนำ" labels={recommendedConditions.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`)}>
{recommendedCards}
</ConditionSwitcher>
)}
@@ -751,9 +751,9 @@ function ArtifactSectionContent({
<CardHeader>
<CardTitle>
{group.name === "Late Game"
? exportText("lateGame", {}, exporting ? "en" : "th")
? (exporting ? "Late Game" : "เลทเกม")
: group.name === "Early Game"
? exportText("earlyGame", {}, exporting ? "en" : "th")
? (exporting ? "Early Game" : "ต้นเกม")
: group.name}
</CardTitle>
</CardHeader>
@@ -806,7 +806,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">{exportText("minimumStats", {}, exporting ? "en" : "th")}</p>
<p className="mb-2 text-sm font-medium">{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) => (
@@ -830,7 +830,7 @@ function ArtifactSectionContent({
) : null}
<span>
{statLabel(
normalizeTargetStat(target.stat) ?? target.stat, exporting ? "en" : "th",
normalizeTargetStat(target.stat) ?? target.stat, exporting,
)}
</span>
</span>
@@ -841,10 +841,10 @@ function ArtifactSectionContent({
</div>
))
) : (
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะเป้าหมาย" labels={profile.targetConditions.filter((group) => group.rows.length).map((group, index) => group.name || `Condition ${index + 1}`)}>
<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">{exportText("minimumStats", {}, exporting ? "en" : "th")}</p>
<p className="mb-2 text-sm font-medium">{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 +865,7 @@ function ArtifactSectionContent({
{profile.talents.length ? (
<div data-artifact-export-section="talents">
<p className="mb-2 text-sm font-medium">
{exportText("talents", {}, exporting ? "en" : "th")}
{exporting ? "Talent Priority" : "ลำดับการอัพพรสวรรค์"}
</p>
<div
className={cn(
@@ -915,7 +915,7 @@ function ArtifactSectionContent({
</div>
<Badge variant="secondary">
{talent.level
? exportText("level", { level: talent.level }, exporting ? "en" : "th")
? (exporting ? `Level ${talent.level}+` : `เลเวล ${talent.level}+`)
: "-"}
</Badge>
<p
@@ -932,7 +932,11 @@ function ArtifactSectionContent({
exporting ? "text-xs" : "text-[11px] sm:text-xs",
)}
>
{exportText(talent.type === "E" ? "skill" : talent.type === "Q" ? "burst" : "normalAttack", {}, exporting ? "en" : "th")}
{talent.type === "E"
? (exporting ? "Elemental Skill" : "สกิลธาตุ")
: talent.type === "Q"
? (exporting ? "Elemental Burst" : "ท่าไม้ตาย")
: (exporting ? "Normal Attack" : "ตีปกติ")}
</p>
</div>
{index < talents.length - 1 ? (
@@ -963,7 +967,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">{exportText("note", {}, exporting ? "en" : "th")}</p>
<p className="mb-2 text-sm font-medium">{exporting ? "Notes" : "โน็ต"}</p>
<p className="text-sm leading-6 text-muted-foreground">
{profile.note}
</p>
@@ -978,8 +982,42 @@ function ArtifactSectionContent({
);
}
function statLabel(value: string, locale: Locale = "th"): string {
return exportStatLabel(value, locale);
const thaiStats: Record<string, string> = {
HP: "พลังชีวิต",
ATK: "พลังโจมตี",
DEF: "พลังป้องกัน",
EM: "ความชำนาญธาตุ",
ER: "ฟื้นฟูพลังงาน",
"CRIT RATE": "อัตราคริ",
"CRIT DAMAGE": "ความแรงคริ",
"ATK%": "พลังโจมตี%",
"HP%": "พลังชีวิต%",
"DEF%": "พลังป้องกัน%",
"CRIT Rate%/CRIT DMG%": "อัตราคริ/ความแรงคริ",
"Pyro DMG%": "ความเสียหายธาตุไฟ%",
"Hydro DMG%": "ความเสียหายธาตุน้ำ%",
"Cryo DMG%": "ความเสียหายธาตุน้ำแข็ง%",
"Electro DMG%": "ความเสียหายธาตุไฟฟ้า%",
"Anemo DMG%": "ความเสียหายธาตุลม%",
"Geo DMG%": "ความเสียหายธาตุดิน%",
"Dendro DMG%": "ความเสียหายธาตุไม้%",
"Physical DMG%": "ความเสียหายกายภาพ%",
"Healing Bonus%": "โบนัสการรักษา%",
};
const englishStats: Record<string, string> = {
HP: "HP",
ATK: "ATK",
DEF: "DEF",
EM: "Elemental Mastery",
ER: "Energy Recharge",
"CRIT RATE": "CRIT Rate",
"CRIT DAMAGE": "CRIT DMG",
"CRIT Rate%/CRIT DMG%": "CRIT Rate / CRIT DMG",
};
function statLabel(value: string, exporting = false): string {
return (exporting ? englishStats : thaiStats)[value] ?? value;
}
function ArtifactStat({
@@ -1080,7 +1118,7 @@ function ArtifactStatValue({
className="size-5 shrink-0 object-contain brightness-0 invert"
/>
) : null}
<span className="break-words">{statLabel(value, exporting ? "en" : "th")}</span>
<span className="break-words">{statLabel(value, exporting)}</span>
</span>
);
}
@@ -1105,7 +1143,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 ? (
@@ -1114,7 +1152,7 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
</p>
) : null}
</header>
<ConditionSwitcher label="เลือกเงื่อนไขกลุ่มดาว" labels={guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, index) => group.name || `Condition ${index + 1}`)}>
<ConditionSwitcher label="เลือกเงื่อนไขกลุ่มดาว" labels={guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}>
{guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, groupIndex, groups) => (
<div key={group.id} className="flex flex-col gap-4">
<div>
@@ -1130,7 +1168,7 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
/>
</GuideImageExport>
</div>
{groups.length > 1 ? <h2 className="font-heading text-2xl font-semibold">{group.name || `Condition ${groupIndex + 1}`}</h2> : null}
{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}
<div className="grid gap-4 md:grid-cols-2">
{group.rows.map((row) => (
@@ -1223,7 +1261,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">{exportText("extraGuide", {}, exporting ? "en" : "th")}</p>
<p className="text-sm font-medium text-primary">{exporting ? "Additional Guide" : "คู่มือเสริม"}</p>
<h1 className="font-heading text-4xl font-semibold">{extra.title}</h1>
{action}
</header>
@@ -1274,7 +1312,7 @@ function ExtraSectionContent({
) : null}
<div className="min-w-0">
<p className="truncate text-sm font-medium">{item.name}</p>
{!item.available ? <Badge variant="secondary">{exportText("missingItem", {}, exporting ? "en" : "th")}</Badge> : null}
{!item.available ? <Badge variant="secondary">{exporting ? "Item unavailable" : "ไม่พบรายการ"}</Badge> : null}
</div>
<ArrowUpRightIcon aria-hidden="true" className="ml-auto shrink-0" />
</a>
+4 -5
View File
@@ -25,7 +25,6 @@ import {
type DpsOrder,
type TeamSearchTarget,
} from "@/lib/guides/team-search";
import { exportText } from "@/lib/guides/export-text";
import { cn } from "@/lib/utils";
type Team = PublicGuide["teams"][number];
@@ -169,7 +168,7 @@ 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]">{exportText("rotation", { seconds: seconds.toLocaleString("en-US", { maximumFractionDigits: 2 }) })}</p>
<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) => {
@@ -206,7 +205,7 @@ function TeamExportImage({ team }: { team: Team }) {
</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]">{exportText("teamCredit")}</div>
<div className="mt-5 flex shrink-0 justify-center text-[40px] text-[#74747f]">By YT Genshin Isn't Mobile Game</div>
</div>
);
}
@@ -401,7 +400,7 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) {
<ToggleGroup aria-label="เลือกเงื่อนไขทีม" variant="outline" value={[activeCondition]} onValueChange={(values) => {
if (values[0]) setActiveCondition(values[0]);
}}>
{groups.map((group, index) => <ToggleGroupItem key={group.id} value={String(index)} className="aria-pressed:bg-primary aria-pressed:text-primary-foreground">{group.name || `Condition ${index + 1}`}</ToggleGroupItem>)}
{groups.map((group, index) => <ToggleGroupItem key={group.id} value={String(index)} className="aria-pressed:bg-primary aria-pressed:text-primary-foreground">{group.name || `เงื่อนไข ${index + 1}`}</ToggleGroupItem>)}
</ToggleGroup>
</ScrollableNav>
) : null}
@@ -464,7 +463,7 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) {
key={team.id}
team={team}
action={
<GuideImageExport fileName={`${team.title}.png`} width={1080} captureClassName="p-0">
<GuideImageExport fileName={`${team.title}.png`} width={1080} captureClassName="p-0" allowWithoutNotes={false}>
<TeamExportImage team={team} />
</GuideImageExport>
}
+9 -10
View File
@@ -14,7 +14,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { exportText } from "@/lib/guides/export-text";
import type { PublicGuide } from "@/lib/guides/queries";
type WeaponGroup = PublicGuide["weaponGroups"][number];
@@ -46,10 +45,10 @@ function WeaponComparisonImage({
<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">
{exportText("weaponTitle", { name: guide.character.name })}
{`${guide.character.name}’s Weapon Comparison`}
</h2>
<p className="mt-1 text-4xl font-bold text-white">
{exportText(metric === "overallPercent" ? "overallDamage" : "personalDamage")}
{metric === "overallPercent" ? "Overall Damage" : "Personal Damage"}
</p>
<p className="mt-2 text-2xl font-semibold">{conditionLabel}</p>
</header>
@@ -111,11 +110,11 @@ export function WeaponImageExport({
group: WeaponGroup;
conditionIndex: number;
}) {
const conditionLabel = group.name || exportText("condition", { index: conditionIndex + 1 });
const conditionLabel = group.name || `Condition ${conditionIndex + 1}`;
const baseName = `${guide.name}-Weapon-${conditionLabel}`;
const metricOptions = ([
["overallPercent", exportText("overallDamage")],
["personalPercent", exportText("personalDamage")],
["overallPercent", "Overall Damage"],
["personalPercent", "Personal Damage"],
] as const).filter(([metric]) =>
group.rows.some((row) => row[metric] !== null),
);
@@ -124,13 +123,13 @@ export function WeaponImageExport({
<Dialog>
<DialogTrigger render={<Button type="button" variant="outline" size="sm" />}>
<DownloadIcon data-icon="inline-start" />
{exportText("exportImage")}
ส่งออกภาพ
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{exportText("damageTitle")}</DialogTitle>
<DialogTitle>เลือกประเภทดาเมจ</DialogTitle>
<DialogDescription>
{exportText("damageDescription")}
เลือกค่าที่ต้องการใช้สร้างภาพเปรียบเทียบอาวุธ
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 sm:grid-cols-2">
@@ -140,7 +139,7 @@ export function WeaponImageExport({
fileName={`${baseName}-${label}.png`}
width={1200}
captureClassName="p-0"
buttonLabel={label}
buttonLabel={metric === "overallPercent" ? "ดาเมจทั้งทีม" : "ดาเมจส่วนตัว"}
>
<WeaponComparisonImage
guide={guide}
-64
View File
@@ -1,64 +0,0 @@
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 { ConstellationImage } from "@/components/public/constellation-image";
import type { PublicGuide } from "@/lib/guides/queries";
import { exportMessages, exportStatLabel, exportText, withoutNotesFileName, type ExportMessageKey } from "@/lib/guides/export-text";
describe("export translations", () => {
it("keeps matching keys and named placeholders in both languages", () => {
expect(Object.keys(exportMessages.th).sort()).toEqual(Object.keys(exportMessages.en).sort());
for (const key of Object.keys(exportMessages.en) as ExportMessageKey[]) {
const placeholders = (text: string) => [...text.matchAll(/\{(\w+)\}/gu)].map((match) => match[1]).sort();
expect(placeholders(exportMessages.th[key]), key).toEqual(placeholders(exportMessages.en[key]));
expect(exportMessages.en[key], key).not.toMatch(/[\u0e00-\u0e7f]/u);
}
});
it("defaults to English and interpolates names without interpreting their contents", () => {
expect(exportText("weaponTitle", { name: "Raiden" })).toBe("Raiden’s Weapon Comparison");
expect(exportText("rotation", { seconds: "20.5" })).toBe("Rotation duration: 20.5 seconds");
expect(exportText("weaponTitle", { name: "{seconds}" })).toBe("{seconds}’s Weapon Comparison");
expect(exportText("condition", { index: 2 }, "th")).toBe("เงื่อนไข 2");
});
it("uses English stat names for exports while retaining Thai page labels and custom values", () => {
expect(exportStatLabel("EM")).toBe("Elemental Mastery");
expect(exportStatLabel("CRIT DAMAGE")).toBe("CRIT DMG");
expect(exportStatLabel("EM", "th")).toBe("ความชำนาญธาตุ");
expect(exportStatLabel("Pyro DMG%")).toBe("Pyro DMG%");
expect(exportStatLabel("Custom value")).toBe("Custom value");
});
it("distinguishes no-note downloads while preserving the image extension", () => {
expect(withoutNotesFileName("guide.png")).toBe("guide-without-notes.png");
expect(withoutNotesFileName("guide.v2.webp")).toBe("guide.v2-without-notes.webp");
expect(withoutNotesFileName("guide")).toBe("guide-without-notes");
});
});
describe("image export controls", () => {
it("always offers both export modes even without notes", () => {
const html = renderToStaticMarkup(<GuideImageExport fileName="guide.png">No notes</GuideImageExport>);
expect(html).toContain("Export image");
expect(html).toContain("Export without note");
expect(html.match(/<button\b/gu)).toHaveLength(2);
});
it("disables both modes when an export selection is empty", () => {
const html = renderToStaticMarkup(<GuideImageExport fileName="guide.png" disabled>No sections</GuideImageExport>);
expect(html.match(/\sdisabled=""/gu)).toHaveLength(2);
});
it.each(["English note", "โน้ตภาษาไทย"])("marks constellation notes for omission regardless of language: %s", (note) => {
const guide = { character: { name: "Raiden", splashImageUrl: "/splash.png" }, coverUrl: "/cover.png" } as PublicGuide;
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).toContain("Team Damage");
expect(html).toContain('data-export-note="true"');
expect(html).toContain(note);
});
});
-158
View File
@@ -1,158 +0,0 @@
export type Locale = "th" | "en";
const english = {
exportImage: "Export image",
withoutNote: "Export without note",
exporting: "Exporting…",
exportError: "Could not export the image. Please try again.",
damageTitle: "Choose a damage metric",
damageDescription: "Choose the damage metric for your weapon comparison image.",
overallDamage: "Overall Damage",
personalDamage: "Personal Damage",
weaponTitle: "{name}’s Weapon Comparison",
condition: "Condition {index}",
sectionTitle: "Choose what to export",
sectionDescription: "Select individual sections or choose All sections to export everything.",
allSections: "All sections",
tierlist: "Artifact Tier List",
bestStats: "Best Main Stats & Substats",
targetStats: "Target Stats",
talents: "Talent Priority",
addNote: "Include note",
recommendedCondition: "Recommended stats condition",
targetCondition: "Target stats condition",
mainStats: "Best Main Stats",
substats: "Best Substats",
sands: "Sands",
goblet: "Goblet",
circlet: "Circlet",
artifactRecommendation: "Recommended Artifacts & Stats",
artifactTitle: "Artifacts & Stats",
lateGame: "Late Game",
earlyGame: "Early Game",
minimumStats: "Recommended Minimum Stats",
level: "Level {level}+",
normalAttack: "Normal Attack",
skill: "Elemental Skill",
burst: "Elemental Burst",
note: "Notes",
extraGuide: "Additional Guide",
missingItem: "Item unavailable",
constellationTitle: "{name}’s Constellations",
teamDamage: "Team Damage",
rotation: "Rotation duration: {seconds} seconds",
teamCredit: "By YT Genshin Is Not a Mobile Game",
disclaimer: "Results cannot be directly compared with other theorycrafters’ calculations,\nas each uses different stats.\nThe figures shown may change in future updates.",
overviewDownloaded: "Overview image downloaded.",
downloadPng: "Download PNG {width}×{height}",
materials: "MATERIALS",
materialLevel: "LV.{level}"
} as const;
export type ExportMessageKey = keyof typeof english;
const thai: Record<ExportMessageKey, string> = {
exportImage: "ส่งออกภาพ",
withoutNote: "ส่งออกภาพโดยไม่รวมโน้ต",
exporting: "กำลังส่งออก…",
exportError: "ส่งออกภาพไม่สำเร็จ กรุณาลองอีกครั้ง",
damageTitle: "เลือกประเภทดาเมจ",
damageDescription: "เลือกค่าที่ต้องการใช้สร้างภาพเปรียบเทียบอาวุธ",
overallDamage: "ดาเมจทั้งทีม",
personalDamage: "ดาเมจส่วนตัว",
weaponTitle: "เปรียบเทียบอาวุธของ {name}",
condition: "เงื่อนไข {index}",
sectionTitle: "เลือกข้อมูลสำหรับส่งออก",
sectionDescription: "เลือกได้หลายส่วน หรือเลือกทั้งหมดเพื่อส่งออกทุกส่วน",
allSections: "ทั้งหมด",
tierlist: "อันดับอาร์ติแฟกต์",
bestStats: "ออฟหลักและออฟรองที่ดีที่สุด",
targetStats: "ค่าสถานะเป้าหมาย",
talents: "ลำดับการอัพพรสวรรค์",
addNote: "รวมโน้ต",
recommendedCondition: "เงื่อนไขค่าสถานะแนะนำ",
targetCondition: "เงื่อนไขค่าสถานะเป้าหมาย",
mainStats: "ออฟหลักที่ดีที่สุด",
substats: "ออฟรองที่ดีที่สุด",
sands: "นาฬิกา",
goblet: "ถ้วย",
circlet: "หัว",
artifactRecommendation: "อาร์ติแฟกต์และสแตทที่แนะนำ",
artifactTitle: "Artifact & Stats",
lateGame: "เลทเกม",
earlyGame: "ต้นเกม",
minimumStats: "ค่าสถานะต่ำๆที่ควรจะปั้นไว้",
level: "เลเวล {level}+",
normalAttack: "ตีปกติ",
skill: "สกิลธาตุ",
burst: "ท่าไม้ตาย",
note: "โน็ต",
extraGuide: "คู่มือเสริม",
missingItem: "ไม่พบรายการ",
constellationTitle: "กลุ่มดาวของ {name}",
teamDamage: "ดาเมจทั้งทีม",
rotation: "ระยะเวลาโรเทชั่น {seconds} วิ",
teamCredit: "By YT เกนชินไม่ใช่เกมมือถือ",
disclaimer: "ไม่สามารถเอาไปเปรียบเทียบกับการคำนวณของ TC ท่านอื่นๆได้\nเพราะแต่ละคนใช้สแตทในการคำนวณที่ไม่เหมือนกัน\nและตารางที่เห็นอาจมีการเปลี่ยนแปลงได้ในอนาคต",
overviewDownloaded: "ดาวน์โหลดภาพ Overview แล้ว",
downloadPng: "ดาวน์โหลด PNG {width}×{height}",
materials: "วัสดุ",
materialLevel: "เลเวล {level}"
};
export const exportMessages = { en: english, th: thai };
export function exportText(
key: ExportMessageKey,
params: Record<string, string | number> = {},
locale: Locale = "en",
): string {
return exportMessages[locale][key].replace(
/\{(\w+)\}/gu,
(placeholder, name: string) => String(params[name] ?? placeholder),
);
}
export function withoutNotesFileName(fileName: string): string {
return /\.[^.]+$/u.test(fileName)
? fileName.replace(/(\.[^.]+)$/u, "-without-notes$1")
: `${fileName}-without-notes`;
}
const thaiStats: Record<string, string> = {
HP: "พลังชีวิต",
ATK: "พลังโจมตี",
DEF: "พลังป้องกัน",
EM: "ความชำนาญธาตุ",
ER: "ฟื้นฟูพลังงาน",
"CRIT RATE": "อัตราคริ",
"CRIT DAMAGE": "ความแรงคริ",
"ATK%": "พลังโจมตี%",
"HP%": "พลังชีวิต%",
"DEF%": "พลังป้องกัน%",
"CRIT Rate%/CRIT DMG%": "อัตราคริ/ความแรงคริ",
"Pyro DMG%": "ความเสียหายธาตุไฟ%",
"Hydro DMG%": "ความเสียหายธาตุน้ำ%",
"Cryo DMG%": "ความเสียหายธาตุน้ำแข็ง%",
"Electro DMG%": "ความเสียหายธาตุไฟฟ้า%",
"Anemo DMG%": "ความเสียหายธาตุลม%",
"Geo DMG%": "ความเสียหายธาตุดิน%",
"Dendro DMG%": "ความเสียหายธาตุไม้%",
"Physical DMG%": "ความเสียหายกายภาพ%",
"Healing Bonus%": "โบนัสการรักษา%",
};
const englishStats: Record<string, string> = {
HP: "HP",
ATK: "ATK",
DEF: "DEF",
EM: "Elemental Mastery",
ER: "Energy Recharge",
"CRIT RATE": "CRIT Rate",
"CRIT DAMAGE": "CRIT DMG",
"CRIT Rate%/CRIT DMG%": "CRIT Rate / CRIT DMG",
};
export function exportStatLabel(value: string, locale: Locale = "en"): string {
return (locale === "th" ? thaiStats : englishStats)[value] ?? value;
}
+60
View File
@@ -0,0 +1,60 @@
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 { MaterialPoster } from "@/components/public/material-poster";
import { ConstellationImage } from "@/components/public/constellation-image";
import type { PublicGuide } from "@/lib/guides/queries";
describe("image export controls", () => {
it("always offers both export modes even without notes", () => {
const html = renderToStaticMarkup(<GuideImageExport fileName="guide.png">No notes</GuideImageExport>);
expect(html).toContain("ส่งออกภาพ");
expect(html).toContain("ส่งออกภาพโดยไม่รวมโน้ต");
expect(html.match(/<button\b/gu)).toHaveLength(2);
});
it("shows only the Thai export button 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);
});
it("disables both modes when an export selection is empty", () => {
const html = renderToStaticMarkup(<GuideImageExport fileName="guide.png" disabled>No sections</GuideImageExport>);
expect(html.match(/\sdisabled=""/gu)).toHaveLength(2);
});
it.each(["English note", "โน้ตภาษาไทย"])("marks constellation notes for omission regardless of language: %s", (note) => {
const guide = { character: { name: "Raiden", splashImageUrl: "/splash.png" }, coverUrl: "/cover.png" } as PublicGuide;
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).toContain("Team Damage");
expect(html).toContain('data-export-note="true"');
expect(html).toContain(note);
});
});
describe("material poster text", () => {
const props = {
character: { name: "Raiden", nameCardImageUrl: null, baseStats: [], ascension: [], leveling: [] },
characterArtUrl: null,
artworkOffsetX: 0,
artworkOffsetY: 0,
artworkScale: 100,
topColors: ["#000000", "#000000"] as [string, string],
bottomColors: ["#000000", "#000000"] as [string, string],
};
it("always uses English, including on the website", () => {
const html = renderToStaticMarkup(<MaterialPoster {...props} />);
expect(html).toContain("MATERIALS");
expect(html).toContain("LV.90");
expect(html).not.toContain("วัสดุ");
expect(html).not.toContain("เลเวล 90");
});
});