diff --git a/app/layout.tsx b/app/layout.tsx index f76e561..07f110b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from "next"; import { Cormorant_Garamond, Inter } from "next/font/google"; import { Analytics } from "@/components/Analytics"; +import { I18nProvider } from "@/lib/i18n/client"; import "./globals.css"; // Editorial fonts: drive tailwind `font-serif`/`font-sans` via @@ -53,7 +54,7 @@ export default function RootLayout({ /> - {children} + {children} diff --git a/app/page.tsx b/app/page.tsx index 0756442..f007864 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -20,6 +20,73 @@ import { AUTH_ENABLED } from "@/lib/supabase/config"; import { isAuthed, writeResumeSnapshot } from "@/lib/authResume"; import { AuthModal } from "@/components/AuthModal"; import { UserChip } from "@/components/UserChip"; +import { LanguageSwitcher } from "@/components/LanguageSwitcher"; +import { useI18n } from "@/lib/i18n/client"; + +// Option value → i18n key suffix maps. The Chinese strings from lib/options.ts +// stay as the underlying identifier (so analytics unions and STYLE_MAP keys +// stay byte-stable); we look up the display label per locale at render time. +const GENDER_KEYS: Record = { + 男性向: "male", + 女性向: "female", + X: "x", +}; + +const ART_STYLE_KEYS: Record = { + "自动": "auto", + "自定义风格": "custom", + "京阿尼": "kyoani", + "新海诚": "shinkai", + "吉卜力": "ghibli", + "黑白漫画": "manga", + "真实": "realistic", + "3D 动画": "3d", + "水墨": "ink", + "仙侠玄幻": "xianxia", + "浮世绘": "ukiyoe", + "敦煌壁画": "dunhuang", + "古典油画": "oil", + "莫奈": "monet", + "水彩": "watercolor", + "细密画": "miniature", + "镶嵌画": "mosaic", + "彩绘玻璃": "stainedGlass", + "赛博朋克": "cyberpunk", + "蒸汽朋克": "steampunk", + "哥特": "gothic", + "废土": "wasteland", + "暗黑童话": "darkFairytale", + "都市幻想": "urbanFantasy", + "像素风": "pixel", + "蒸汽波": "vaporwave", + "矢量插画": "vector", + "低多边形": "lowpoly", + "波普艺术": "popart", + "故障艺术": "glitch", + "彩铅": "pencil", + "手绘素描": "sketch", + "剪纸艺术": "papercut", + "儿童绘本": "children", + "儿童涂鸦": "crayon", + "黏土手工": "clay", +}; + +const PLOT_STYLE_KEYS: Record = { + "平铺直叙": "straightforward", + "多线转折": "twist", + "悬疑烧脑": "suspense", + "治愈日常": "healing", +}; + +const PACING_KEYS: Record = { + "慢热细腻": "slow", + "紧凑爽快": "fast", +}; + +const VOICE_KEYS: Record = { + "关闭": "off", + "开启": "on", +}; /* ============================================================================ InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型) @@ -30,39 +97,26 @@ import { UserChip } from "@/components/UserChip"; ========================================================================== */ -const EXAMPLE_PHRASES: Record = { - 男性向: [ - "从小一起长大的青梅竹马,突然红着脸向我告白", - "一觉醒来,班上的女生好像都偷偷喜欢上了我", - "三年之期已到,原来我是富家公子,报仇时机已到", - "我带着无限 Token 穿越回了互联网诞生前夕……", - ], - 女性向: [ - "穿越成将军府的废物嫡女,冷面摄政王却独宠我一人", - "重生回到分手前夜,这一次换我先放手", - "一觉醒来成了乙游里的恶役千金,要躲开所有死亡结局", - ], - X: [ - "时空裂隙开启,多个平行世界的自己突然出现在眼前", - "记忆宫殿里,那些被遗忘的碎片正在重组为新的故事", - "一场无限流游戏开始,所有人都有唯一的通关机会", - "系统提示:你的选择将决定整个宇宙的命运走向", - ], -}; +// EXAMPLE_PHRASES is now sourced from i18n (home.examples.{male,female,x}). +// The Chinese values below are kept as gender identifiers only — they're the +// underlying session value and flow into analytics as a stable literal union. type Opt = { label: string; items: string[]; defaultIndex?: number; modal?: boolean; + // i18n key suffixes — used to render localized display labels for each item. + itemKey: string; + labelKey: string; }; const OPTS: Opt[] = [ - { label: "性向", items: [...GENDERS] }, - { label: "绘画风格", modal: true, items: [...ART_STYLES] }, - { label: "剧情风格", items: [...PLOT_STYLES], defaultIndex: 1 }, - { label: "语音配音", items: ["关闭", "开启"], defaultIndex: 1 }, - { label: "内容节奏", items: [...PACINGS], defaultIndex: 1 }, + { label: "性向", items: [...GENDERS], labelKey: "home.options.gender", itemKey: "home.genders" }, + { label: "绘画风格", modal: true, items: [...ART_STYLES], labelKey: "home.options.artStyle", itemKey: "home.artStyles" }, + { label: "剧情风格", items: [...PLOT_STYLES], defaultIndex: 1, labelKey: "home.options.plotStyle", itemKey: "home.plotStyles" }, + { label: "语音配音", items: ["关闭", "开启"], defaultIndex: 1, labelKey: "home.options.voice", itemKey: "home.voiceOptions" }, + { label: "内容节奏", items: [...PACINGS], defaultIndex: 1, labelKey: "home.options.pacing", itemKey: "home.pacings" }, ]; type StoryContent = { title: string; outline: string; style: string; tags: string[] }; @@ -822,6 +876,7 @@ function StoryCard({ function CategorySelect({ label, items, + itemLabels, value, open, onToggle, @@ -829,6 +884,7 @@ function CategorySelect({ }: { label: string; items: string[]; + itemLabels: string[]; value: number; open: boolean; onToggle: () => void; @@ -843,7 +899,7 @@ function CategorySelect({ > {label} - {items[value]} + {itemLabels[value] ?? items[value]} - {it} + {itemLabels[i] ?? it} {i === value && } ))} @@ -914,6 +970,7 @@ async function extractStylePromptFromImage(resized: string): Promise { function StyleModal({ items, + itemLabels, value, onPick, onClose, @@ -924,6 +981,7 @@ function StyleModal({ onRequireAuth, }: { items: string[]; + itemLabels: string[]; value: number; onPick: (i: number) => void; onClose: () => void; @@ -933,6 +991,7 @@ function StyleModal({ setCustomStyleRefImage: (s: string) => void; onRequireAuth: () => void; }) { + const { t } = useI18n(); const [q, setQ] = useState(""); const [shown, setShown] = useState(false); const [view, setView] = useState<"grid" | "custom">("grid"); @@ -1011,13 +1070,13 @@ function StyleModal({ const dataUrl = await new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result)); - r.onerror = () => reject(new Error("读取文件失败")); + r.onerror = () => reject(new Error(t("home.styleModal.fileReadError"))); r.readAsDataURL(file); }); const img = await new Promise((resolve, reject) => { const i = new Image(); i.onload = () => resolve(i); - i.onerror = () => reject(new Error("无法解码图片")); + i.onerror = () => reject(new Error(t("home.styleModal.imageDecodeError"))); i.src = dataUrl; }); const MAX_DIM = 512; @@ -1040,7 +1099,7 @@ function StyleModal({ const handleUploadStyleImage = async (file: File) => { setParseError(null); if (!file.type.startsWith("image/")) { - setParseError("只支持图片文件"); + setParseError(t("home.styleModal.uploadError")); return; } setParsing(true); @@ -1058,12 +1117,12 @@ function StyleModal({ return; } const stylePrompt = await extractStylePromptFromImage(resized); - if (!stylePrompt) throw new Error("视觉模型返回了空的风格描述"); + if (!stylePrompt) throw new Error(t("home.styleModal.visionError")); setDraft(stylePrompt); setCustomStyleRefImage(resized); track("style_image_upload", { ok: true }); } catch (err) { - const msg = err instanceof Error ? err.message : "解析失败"; + const msg = err instanceof Error ? err.message : t("home.styleModal.parseError"); setParseError(msg); track("style_image_upload", { ok: false }); } finally { @@ -1077,9 +1136,10 @@ function StyleModal({ }; const q2 = q.trim(); - const list = items.map((name, i) => ({ name, i })).filter((x) => { + const list = items.map((name, i) => ({ name, label: itemLabels[i] ?? name, i })).filter((x) => { if (!q2) return true; - return x.name.toLowerCase().includes(q2.toLowerCase()); + const needle = q2.toLowerCase(); + return x.name.toLowerCase().includes(needle) || x.label.toLowerCase().includes(needle); }); return (
setView("grid")} className="flex h-8 w-8 items-center justify-center rounded-sm text-clay-500 hover:bg-cream-100 hover:text-clay-900 transition-colors" - aria-label="返回" + aria-label={t("home.ui.back")} > - 自定义风格 + {t("home.styleModal.customTitle")}
) : ( <>
- 选择绘画风格 + {t("home.styleModal.title")} - 默认「自动」· 由 AI 根据故事自动匹配画风;选择「自定义风格」可输入描述或上传参考图 + {t("home.styleModal.subtitle")}
setQ(e.target.value)} - placeholder="搜索风格…" + placeholder={t("home.ui.searchPlaceholder")} autoFocus className="h-10 w-full rounded-sm border border-clay-900/15 bg-cream-100 pl-4 pr-10 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400" /> @@ -1132,7 +1192,7 @@ function StyleModal({
) : ( @@ -1206,12 +1266,12 @@ function StyleModal({ {parsing ? ( <> - 解析中… + {t("home.styleModal.parsing")} ) : ( <> - 上传参考图 + {t("home.styleModal.uploadImage")} )} @@ -1224,7 +1284,7 @@ function StyleModal({ }} className="h-8 w-36 md:w-44 rounded-sm border border-clay-900/15 bg-cream-50 px-2 font-sans text-[12px] text-clay-700 outline-none transition-colors focus:border-ember-500" > - + {Object.keys(STYLE_MAP).map((s) => ( ))} @@ -1235,7 +1295,7 @@ function StyleModal({ onClick={() => setView("grid")} className="rounded-sm border border-clay-900/15 px-4 py-1.5 font-sans text-xs text-clay-700 hover:border-clay-900/30 hover:text-clay-900 transition-colors" > - 取消 + {t("home.ui.cancel")} ) : (
- {list.map(({ name, i }) => { + {list.map(({ name, label, i }) => { const isCustom = name === "自定义风格"; const thumb = STYLE_THUMB[name]; return ( @@ -1288,20 +1348,20 @@ function StyleModal({
{thumb ? ( /* eslint-disable-next-line @next/next/no-img-element */ - {name} + {label} ) : (
)}
- {name} + {label}
); })} {list.length === 0 && (
- 没有匹配的风格 + {t("home.ui.noMatchingStyle")}
)}
@@ -1315,6 +1375,7 @@ function StyleModal({ export default function HomePage() { const router = useRouter(); + const { t, locale, tArray } = useI18n(); const [sel, setSel] = useState(OPTS.map((o) => o.defaultIndex ?? 0)); const [open, setOpen] = useState(-1); @@ -1344,7 +1405,43 @@ export default function HomePage() { const paceRow = OPTS.findIndex((o) => o.label === "内容节奏"); const genderIndex = sel[0] ?? 0; const gender = (OPTS[0]!.items[genderIndex] as Gender) ?? "男性向"; - const phrases = EXAMPLE_PHRASES[gender]; + // Display labels for each option category — localized at render time. The + // underlying `items` are kept as Chinese literal identifiers because they + // flow into analytics unions and `STYLE_MAP` keys. + const optItemLabels = OPTS.map((o) => { + if (o.itemKey === "home.genders") { + return o.items.map((v) => t(`home.genders.${GENDER_KEYS[v as Gender] ?? "male"}`)); + } + if (o.itemKey === "home.artStyles") { + return o.items.map((v) => { + const k = ART_STYLE_KEYS[v]; + return k ? t(`home.artStyles.${k}`) : v; + }); + } + if (o.itemKey === "home.plotStyles") { + return o.items.map((v) => { + const k = PLOT_STYLE_KEYS[v]; + return k ? t(`home.plotStyles.${k}`) : v; + }); + } + if (o.itemKey === "home.pacings") { + return o.items.map((v) => { + const k = PACING_KEYS[v]; + return k ? t(`home.pacings.${k}`) : v; + }); + } + if (o.itemKey === "home.voiceOptions") { + return o.items.map((v) => { + const k = VOICE_KEYS[v]; + return k ? t(`home.voiceOptions.${k}`) : v; + }); + } + return o.items; + }); + const optLabels = OPTS.map((o) => t(o.labelKey)); + const phrasesKey = GENDER_KEYS[gender] ?? "male"; + const phrases = tArray(`home.examples.${phrasesKey}`); + void locale; // 当前 Typewriter 闪动到第几句——start() 空输入时会拿它做默认故事种子, // 实现「所见即所玩」。切性向时重置,否则索引可能越界。 const [phraseIdx, setPhraseIdx] = useState(0); @@ -1590,13 +1687,13 @@ export default function HomePage() { setStoryImportError(null); if (!file) return; if (file.size <= 0) { - setStoryImportError("这个剧情文件是空的。"); + setStoryImportError(t("home.errors.emptyFile")); return; } const isJson = file.name.toLowerCase().endsWith(".json") || file.type === "application/json"; const maxImportBytes = isJson ? 12_000_000 : 13_000_000; if (file.size > maxImportBytes) { - setStoryImportError("剧情文件太大,无法载入。"); + setStoryImportError(t("home.errors.fileTooLarge")); return; } try { @@ -1610,17 +1707,17 @@ export default function HomePage() { }); if (!r.ok) { const j = (await r.json().catch(() => ({}))) as { error?: string }; - throw new Error(j.error ?? "剧情文件解包失败。"); + throw new Error(j.error ?? t("home.errors.unpackFailed")); } const j = (await r.json()) as { docStr?: unknown }; - if (typeof j.docStr !== "string") throw new Error("剧情文件解包失败。"); + if (typeof j.docStr !== "string") throw new Error(t("home.errors.unpackFailed")); text = j.docStr; } const doc = parseStoryShareDoc(JSON.parse(text)); window.sessionStorage.setItem(STORY_SHARE_STORAGE_KEY, JSON.stringify(doc)); router.push("/play?share=1"); } catch (e) { - setStoryImportError(e instanceof Error ? e.message : "剧情文件解析失败。"); + setStoryImportError(e instanceof Error ? e.message : t("home.errors.parseFailed")); } finally { if (storyImportRef.current) storyImportRef.current.value = ""; } @@ -1664,14 +1761,15 @@ export default function HomePage() { InfiPlot
+
@@ -1775,7 +1873,7 @@ export default function HomePage() { )} {prompt && (

- Enter 发送 · Shift+Enter 换行 + {t("home.hero.enterHint")}

)} @@ -1785,8 +1883,9 @@ export default function HomePage() { {OPTS.map((o, r) => (
{ @@ -1810,16 +1909,14 @@ export default function HomePage() { {/* 使用提示:可被用户永久关闭(localStorage:infiplot:hintClosed) */} {!hintClosed && (
-

- 输入想法、配置风格,点击「开始」即可游玩{AUTH_ENABLED && "(测试期间,登录即可免费畅玩)"};也可以从下方精选故事集挑一篇快速体验{" "} - InfiPlot。 - 点击「设置」还能填入你的名字,以及你自己的文本、绘图、识图模型和配音 - Key——全部只存在本地浏览器,体验更稳定。 -

+

} belowCanvas={ @@ -2618,22 +2627,22 @@ function PlayInner() { onClick={() => void handleExportGallery()} disabled={!!exportProgress} className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2 disabled:opacity-50" - aria-label="导出可交互图集" - title="导出本局为可交互图集链接(含配音;只会保留最近两次的可交互图集链接)" + aria-label={t("play.tooltips.exportGalleryLabel")} + title={t("play.tooltips.exportGallery")} > - 导 · 出 · 图 · 集 + {t("play.buttons.exportGallery")} ) : null @@ -2644,13 +2653,13 @@ function PlayInner() { type="button" onClick={toggleMuted} className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2" - aria-label={muted ? "取消静音" : "静音"} - title={muted ? "取消静音" : "静音"} + aria-label={muted ? t("play.tooltips.unmute") : t("play.tooltips.mute")} + title={muted ? t("play.tooltips.unmute") : t("play.tooltips.mute")} > - {muted ? "静 · 音" : "有 · 声"} + {muted ? t("play.buttons.muted") : t("play.buttons.sound")} {/* Silence nudge — a compact pill right beside the mute toggle. @@ -2665,16 +2674,16 @@ function PlayInner() { type="button" onClick={() => setSettingsOpen(true)} className="inline-flex items-center gap-1.5 rounded-full border border-ember-500/40 bg-ember-500/10 px-2.5 py-1 text-[10px] text-ember-500 hover:bg-ember-500/20 transition-colors" - title="效果不满意/经常没声音?填入自己的 API Key 试试" + title={t("play.tooltips.silenceNudge")} > - 效果不满意/经常没声音?填入自己的 API Key 试试 + {t("play.tooltips.silenceNudge")} @@ -154,7 +156,7 @@ export function AuthModal({ className="flex w-full items-center justify-center gap-2.5 rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-4 py-2.5 text-[13px] text-cream-50/90 transition-colors hover:bg-cream-50/[0.12] disabled:opacity-50" > - Google 登录 + {t("auth.googleLogin")}

- + {t("auth.or")}
)} @@ -188,7 +190,7 @@ export function AuthModal({ value={email} onChange={(e) => setEmail(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleSendOtp()} - placeholder="your@email.com" + placeholder={t("auth.emailPlaceholder")} autoFocus className="w-full rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-3.5 py-2.5 text-[13px] text-cream-50/90 placeholder:text-cream-50/30 outline-none focus:border-[rgba(175,138,72,0.6)]" /> @@ -198,7 +200,7 @@ export function AuthModal({ onClick={handleSendOtp} className="w-full rounded-md bg-[rgba(175,138,72,0.85)] px-4 py-2.5 text-[13px] font-medium text-cream-50 transition-colors hover:bg-[rgba(175,138,72,1)] disabled:opacity-50" > - {loading ? "发送中..." : "发送验证码"} + {loading ? t("auth.sending") : t("auth.sendCode")} )} @@ -216,7 +218,7 @@ export function AuthModal({ {step === "otp-verify" && ( <>

- 验证码已发送至 {email.trim()} + {t("auth.codeSent", { email: email.trim() })}

setOtp(e.target.value.replace(/\D/g, ""))} onKeyDown={(e) => e.key === "Enter" && handleVerifyOtp()} - placeholder="6 位验证码" + placeholder={t("auth.codePlaceholder")} autoFocus className="w-full rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-3.5 py-2.5 text-center text-[16px] tracking-[0.35em] text-cream-50/90 placeholder:text-cream-50/30 placeholder:tracking-normal outline-none focus:border-[rgba(175,138,72,0.6)]" /> @@ -235,7 +237,7 @@ export function AuthModal({ onClick={handleVerifyOtp} className="w-full rounded-md bg-[rgba(175,138,72,0.85)] px-4 py-2.5 text-[13px] font-medium text-cream-50 transition-colors hover:bg-[rgba(175,138,72,1)] disabled:opacity-50" > - {loading ? "验证中..." : "确认"} + {loading ? t("auth.verifying") : t("auth.verify")} )} diff --git a/components/CustomForm.tsx b/components/CustomForm.tsx index ba45a1c..f86d7a6 100644 --- a/components/CustomForm.tsx +++ b/components/CustomForm.tsx @@ -3,9 +3,11 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { track } from "@/lib/analytics"; +import { useI18n } from "@/lib/i18n/client"; export function CustomForm() { const router = useRouter(); + const { t } = useI18n(); const [worldSetting, setWorldSetting] = useState(""); const [styleGuide, setStyleGuide] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -35,7 +37,7 @@ export function CustomForm() { - World · 世界观 + {t("customForm.world")} {worldSetting.length} @@ -45,7 +47,7 @@ export function CustomForm() { value={worldSetting} onChange={(e) => setWorldSetting(e.target.value)} rows={6} - placeholder="例:1990 年代末的中国南方县城。主角是高三转学生,在多雨的六月遇到一个总在天台读诗的同学。剧情慢热、含蓄、带点伤感⋯" + placeholder={t("customForm.worldPlaceholder")} className="w-full bg-transparent border-0 border-b border-clay-900/20 px-0 py-3 text-clay-900 font-serif text-lg leading-[1.7] focus:outline-none focus:border-clay-700 transition-colors resize-none placeholder:font-serif placeholder:italic placeholder:text-base placeholder:leading-[1.7]" />
@@ -56,7 +58,7 @@ export function CustomForm() { - Style · 画风 + {t("customForm.style")} {styleGuide.length} @@ -66,7 +68,7 @@ export function CustomForm() { value={styleGuide} onChange={(e) => setStyleGuide(e.target.value)} rows={4} - placeholder="例:水彩柔光,午后暖意,动漫视觉小说画风,传统对话面板⋯" + placeholder={t("customForm.stylePlaceholder")} className="w-full bg-transparent border-0 border-b border-clay-900/20 px-0 py-3 text-clay-900 font-serif text-lg leading-[1.7] focus:outline-none focus:border-clay-700 transition-colors resize-none placeholder:font-serif placeholder:italic placeholder:text-base placeholder:leading-[1.7]" />
@@ -74,17 +76,17 @@ export function CustomForm() {
{submitting - ? "正在唤起第一帧…" + ? t("customForm.status.starting") : canSubmit - ? "准 · 备 · 就 · 绪" - : "两 · 段 · 即 · 可 · 开 · 场"} + ? t("customForm.status.ready") + : t("customForm.status.needMore")} diff --git a/components/DialogueHistoryModal.tsx b/components/DialogueHistoryModal.tsx index 44d60be..566e3dc 100644 --- a/components/DialogueHistoryModal.tsx +++ b/components/DialogueHistoryModal.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef } from "react"; +import { useI18n } from "@/lib/i18n/client"; export type DialogueHistoryItem = { id: string; @@ -23,6 +24,7 @@ export function DialogueHistoryModal({ onClose: () => void; playerName?: string; }) { + const { t } = useI18n(); const displaySpeaker = (s: string | undefined) => s === "你" && playerName ? playerName : s; const listRef = useRef(null); @@ -63,19 +65,19 @@ export function DialogueHistoryModal({ }} role="dialog" aria-modal="true" - aria-label="剧情回溯" + aria-label={t("history.ariaLabel")} >
- 剧 · 情 · 回 · 溯 + {t("history.title")}
@@ -89,7 +91,7 @@ export function DialogueHistoryModal({ > {items.length === 0 ? (

- 暂无历史。 + {t("history.noHistory")}

) : (
@@ -97,7 +99,7 @@ export function DialogueHistoryModal({
- 第 {String(item.sceneIndex).padStart(3, "0")} 幕 + {t("history.scene", { n: String(item.sceneIndex).padStart(3, "0") })} {item.speaker && ( @@ -128,7 +130,7 @@ export function DialogueHistoryModal({ {item.selectedChoice && (

- 选择 + {t("history.choice")} {item.selectedChoice}

@@ -136,7 +138,7 @@ export function DialogueHistoryModal({ {item.freeformAction && (

- 行动 + {t("history.action")} {item.freeformAction}

diff --git a/components/LanguageSwitcher.tsx b/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..dea38ee --- /dev/null +++ b/components/LanguageSwitcher.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useState } from "react"; +import { useI18n } from "@/lib/i18n/client"; +import { LOCALES, LOCALE_NAMES, type Locale } from "@/lib/i18n/config"; + +interface LanguageSwitcherProps { + className?: string; + /** "compact" = icon + short label, fits a header next to other icons. + * "full" = icon + full label + chevron, for a settings panel row. */ + variant?: "compact" | "full"; +} + +// Locales with actual filled-in translations. The catalog ships stub files +// for the other 18 locales (so the loader doesn't 404), but only these +// three have been reviewed. Hide the rest until they're translated. +const TRANSLATED_LOCALES: Locale[] = ["zh-CN", "en", "ja"]; + +// Short labels for the compact header button — keeps the row tidy next to +// the gear/github/x icons where every other item is 1-2 glyphs. +const SHORT_LOCALE_NAMES: Record = { + "zh-CN": "中文", + "zh-TW": "繁中", + "zh-HK": "繁中", + en: "EN", + ja: "日本語", + ko: "한국어", + es: "ES", + fr: "FR", + de: "DE", + "pt-BR": "PT", + pt: "PT", + ru: "RU", + it: "IT", + vi: "VI", + th: "TH", + id: "ID", + tr: "TR", + pl: "PL", + nl: "NL", + uk: "UK", + hi: "हिन्दी", + cs: "CZ", +}; + +export function LanguageSwitcher({ className = "", variant = "full" }: LanguageSwitcherProps) { + const { locale, setLocale, t } = useI18n(); + const [isOpen, setIsOpen] = useState(false); + + const currentLocaleName = LOCALE_NAMES[locale] || locale; + const currentShortName = SHORT_LOCALE_NAMES[locale] || locale; + const availableLocales = LOCALES.filter((l) => TRANSLATED_LOCALES.includes(l)); + + return ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} + aria-hidden="true" + /> +
+
+ {availableLocales.map((loc) => ( + + ))} +
+
+ + )} +
+ ); +} + diff --git a/components/PlayCanvas.tsx b/components/PlayCanvas.tsx index 030641c..fc51993 100644 --- a/components/PlayCanvas.tsx +++ b/components/PlayCanvas.tsx @@ -6,6 +6,7 @@ import { type DialogueHistoryItem, } from "@/components/DialogueHistoryModal"; import type { Beat, BeatChoice, Orientation } from "@infiplot/types"; +import { useI18n } from "@/lib/i18n/client"; export type Phase = | "loading-first" // first scene not yet rendered @@ -216,6 +217,7 @@ export function PlayCanvas({ disabledChoiceIds?: readonly string[]; freeformDisabled?: boolean; }) { + const { t } = useI18n(); const imgRef = useRef(null); const audioRef = useRef(null); const [historyOpen, setHistoryOpen] = useState(false); @@ -401,7 +403,7 @@ export function PlayCanvas({ src={imageUrl} width={intrinsicW} height={intrinsicH} - alt="Generated scene" + alt={t("play.imageAlt")} onClick={handleImageClick} draggable={false} onLoad={() => { @@ -492,7 +494,7 @@ export function PlayCanvas({ setFreeformText(""); } }} - placeholder="输入你想说的或想做的..." + placeholder={t("play.freeform.placeholder")} maxLength={50} autoFocus className="flex-1 min-w-0 bg-transparent border-none outline-none font-serif text-[14px] placeholder:text-[rgba(200,185,155,0.50)]" @@ -531,7 +533,7 @@ export function PlayCanvas({ index={i} label={choice.label} disabled={phase !== "ready" || disabledChoices.has(choice.id)} - disabledTitle={disabledChoices.has(choice.id) ? "分享剧情未包含这条分支" : undefined} + disabledTitle={disabledChoices.has(choice.id) ? t("play.choiceDisabled") : undefined} vertical={portrait} onClick={() => onSelectChoice(choice)} /> @@ -554,7 +556,7 @@ export function PlayCanvas({ width: portrait ? "100%" : "42px", padding: portrait ? "10px 16px" : "0", }} - title="自由输入" + title={t("play.freeform.title")} > - 自由输入 + {t("play.freeform.title")} ) : ( @@ -667,8 +669,8 @@ export function PlayCanvas({ onOpenSettings(); }} className="absolute bottom-[6px] right-[8px] flex h-7 w-7 items-center justify-center text-[rgba(195,155,75,0.78)] transition-colors hover:text-[rgba(245,235,210,0.96)]" - aria-label="打开设置" - title="设置" + aria-label={t("play.tooltips.openSettings")} + title={t("home.ui.settings")} > @@ -683,8 +685,8 @@ export function PlayCanvas({ className={`absolute bottom-[6px] ${ onOpenSettings ? "right-[40px]" : "right-[8px]" } flex h-7 w-7 items-center justify-center text-[rgba(195,155,75,0.78)] transition-colors hover:text-[rgba(245,235,210,0.96)]`} - aria-label="打开剧情回溯" - title="剧情回溯" + aria-label={t("play.tooltips.openHistory")} + title={t("play.tooltips.openHistory")} > @@ -697,8 +699,8 @@ export function PlayCanvas({

{phase === "transitioning" - ? "AI · 正 · 在 · 描 · 画 · 下 · 一 · 幕" - : "AI · 正 · 在 · 想 · 你 · 看 · 到 · 了 · 什 · 么"} + ? t("play.loading.transitioning") + : t("play.loading.visionThinking")}

)} @@ -742,7 +744,7 @@ export function PlayCanvas({ >

- 正 · 在 · 绘 · 制 · 第 · 一 · 幕 + {t("play.loading.firstFrame")}

{/* 加载占位也挂同一对 slot,让右上 / 左上的操作按钮在第一帧就出现 */} {!fullViewport && aboveCanvas && ( diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx index 808c115..051f9fc 100644 --- a/components/SettingsModal.tsx +++ b/components/SettingsModal.tsx @@ -18,6 +18,7 @@ import { TTS_KEY_DOC_URL, TTS_REGION_PRESETS, } from "@/lib/ttsPresets"; +import { useI18n } from "@/lib/i18n/client"; const PLAYER_NAME_STORAGE_KEY = "infiplot:playerName"; const VISION_CLICK_STORAGE_KEY = "infiplot:visionClick"; @@ -50,10 +51,10 @@ export function readStoredVisionClick(): boolean { } } -const PROVIDER_OPTIONS: { value: ProviderProtocol | ""; label: string }[] = [ - { value: "", label: "自动推断(推荐)" }, - { value: "openai_compatible", label: "OpenAI Compatible" }, - { value: "runware", label: "Runware" }, +const PROVIDER_OPTIONS: { value: ProviderProtocol | ""; labelKey: string; fallback: string }[] = [ + { value: "", labelKey: "settings.models.providerAuto", fallback: "Auto-detect" }, + { value: "openai_compatible", labelKey: "", fallback: "OpenAI Compatible" }, + { value: "runware", labelKey: "", fallback: "Runware" }, ]; type ModelGroup = { @@ -85,6 +86,7 @@ export function SettingsModal({ }) => void; footerNote?: ReactNode; }) { + const { t } = useI18n(); const [activeTab, setActiveTab] = useState(initialTab); // ── General tab state ── @@ -96,7 +98,7 @@ export function SettingsModal({ const [groups, setGroups] = useState([ { key: "text", - label: "文本模型", + label: "text", icon: "fa-solid fa-pen-nib", baseUrl: initial?.textBaseUrl ?? "", apiKey: initial?.textApiKey ?? "", @@ -105,7 +107,7 @@ export function SettingsModal({ }, { key: "image", - label: "绘图模型", + label: "image", icon: "fa-solid fa-palette", baseUrl: initial?.imageBaseUrl ?? "", apiKey: initial?.imageApiKey ?? "", @@ -114,7 +116,7 @@ export function SettingsModal({ }, { key: "vision", - label: "识图模型", + label: "vision", icon: "fa-solid fa-eye", baseUrl: initial?.visionBaseUrl ?? "", apiKey: initial?.visionApiKey ?? "", @@ -254,10 +256,17 @@ export function SettingsModal({ const hasAnySetting = hasGeneralSetting || hasModelSetting; const tabs: { key: TabKey; label: string; icon: string }[] = [ - { key: "general", label: "通用", icon: "fa-solid fa-sliders" }, - { key: "models", label: "模型", icon: "fa-solid fa-microchip" }, + { key: "general", label: t("settings.tabs.general"), icon: "fa-solid fa-sliders" }, + { key: "models", label: t("settings.tabs.models"), icon: "fa-solid fa-microchip" }, ]; + const groupLabel = (k: string) => + k === "text" + ? t("settings.models.textModel") + : k === "image" + ? t("settings.models.imageModel") + : t("settings.models.visionModel"); + return (
- 设置 + {t("settings.title")} - 可选 · 这些设置仅保存在本地浏览器 + {t("settings.subtitle")}
- NPC 会在对话中用这个名字称呼你。不填则默认以「你」称呼。 + {t("settings.general.playerNameHint")}
@@ -356,22 +365,22 @@ export function SettingsModal({ - 点击画面识别 + {t("settings.general.visionClick")}
{( [ - { on: true, label: "开启", icon: "fa-solid fa-wand-magic-sparkles" }, - { on: false, label: "关闭", icon: "fa-solid fa-ban" }, + { on: true, labelKey: "settings.general.visionOn", icon: "fa-solid fa-wand-magic-sparkles" }, + { on: false, labelKey: "settings.general.visionOff", icon: "fa-solid fa-ban" }, ] as const - ).map((t) => { - const active = visionClick === t.on; + ).map((opt) => { + const active = visionClick === opt.on; return ( ); })}
- 开启后,在选择节点点击画面会触发 AI 识图并生成新的剧情分支。 + {t("settings.general.visionHint")}
@@ -405,7 +414,7 @@ export function SettingsModal({

- 请确保你的 API 端点支持浏览器跨域请求(CORS)。大多数主流提供商(OpenAI、Anthropic、Gemini、Runware 等)已默认支持。 + {t("settings.models.corsNotice")}

@@ -422,13 +431,13 @@ export function SettingsModal({ - {g.label} + {groupLabel(g.key)}
- BASE URL + {t("settings.models.baseUrl")} - API Key + {t("settings.models.apiKey")}
- Model + {t("settings.models.model")} - Provider(可选) + {t("settings.models.provider")} - 留空时系统会根据 Base URL 自动推断协议。 + {t("settings.models.providerHint")}
@@ -520,43 +529,39 @@ export function SettingsModal({ - 配音模型 + {t("settings.tts.title")}
-

- 填入你自己的 - 小米 MiMo API Key - ,配音将在浏览器本地合成,Key 只保存在本地、绝不经过服务器。MiMo - TTS 目前 - 限时免费 - ,申请即可使用。 -

+

- Key 类型 + {t("settings.tts.keyType")}
{( [ { kind: "payg", - label: "按量付费 Pay-as-you-go", - sub: "sk- 开头", + labelKey: "settings.tts.payg", + subKey: "settings.tts.paygSub", }, { kind: "token-plan", - label: "套餐 Token Plan", - sub: "tp- 开头", + labelKey: "settings.tts.tokenPlan", + subKey: "settings.tts.tokenPlanSub", }, ] as const - ).map((t) => { - const active = keyType === t.kind; + ).map((opt) => { + const active = keyType === opt.kind; return ( ); @@ -577,7 +582,7 @@ export function SettingsModal({ {keyType === "token-plan" && (
- 区域节点 + {t("settings.tts.region")}
{TTS_REGION_PRESETS.map((p) => { @@ -600,14 +605,14 @@ export function SettingsModal({ })}
- 选择与你的套餐订阅地区一致的节点(通常也是延迟最低的那个)。 + {t("settings.tts.regionHint")}
)}
- API Key + {t("settings.models.apiKey")}
@@ -668,7 +671,7 @@ export function SettingsModal({ className="inline-flex items-center gap-2 rounded-sm border border-clay-900/15 px-4 py-2 font-sans text-sm text-clay-600 transition-colors hover:border-clay-900/35 hover:text-clay-900" > - 全部清除 + {t("settings.actions.clearAll")} )}
diff --git a/docs/i18n-implementation.md b/docs/i18n-implementation.md new file mode 100644 index 0000000..9932f32 --- /dev/null +++ b/docs/i18n-implementation.md @@ -0,0 +1,183 @@ +# InfiPlot i18n Implementation + +## Summary + +A complete i18n infrastructure has been implemented for InfiPlot, enabling support for 22 languages: + +- English (en) +- Simplified Chinese (zh-CN) - Source language +- Traditional Chinese Taiwan (zh-TW) +- Traditional Chinese Hong Kong (zh-HK) +- Japanese (ja) +- Korean (ko) +- Spanish (es) +- French (fr) +- German (de) +- Portuguese Brazil (pt-BR) +- Portuguese (pt) +- Russian (ru) +- Italian (it) +- Vietnamese (vi) +- Thai (th) +- Indonesian (id) +- Turkish (tr) +- Polish (pl) +- Dutch (nl) +- Ukrainian (uk) +- Hindi (hi) +- Czech (cs) + +## What Was Implemented + +### 1. Core i18n Infrastructure (`lib/i18n/`) + +- **config.ts**: Locale configuration, locale names, storage key management +- **types.ts**: TypeScript types for translation system +- **utils.ts**: Helper functions for nested value access, string formatting +- **client.tsx**: React context provider and `useI18n()` hook for client components +- **server.ts**: Server-side translation utilities for Next.js App Router +- **index.ts**: Main export file + +### 2. Translation Files (`lib/i18n/locales/`) + +- **zh-CN.ts**: Complete source translations (Chinese) +- **en.ts**: Reference English translations +- Additional locale files will be generated by the translation script + +### 3. Translation Script (`scripts/translate-i18n.mjs`) + +A Node.js script that: +- Reads the source zh-CN translation file +- Uses LLM APIs (Gemini or OpenAI-compatible) to translate to all target languages +- Preserves structure and handles special cases: + - Placeholder variables (`{{email}}`, `{n}`, etc.) + - HTML tags (``, ``, etc.) + - Select/message format syntax + - Proper nouns (InfiPlot, GitHub, Google, etc.) +- Generates TypeScript locale files +- Updates client.tsx and server.ts imports automatically + +Usage: +```bash +# With Gemini +node scripts/translate-i18n.mjs --provider gemini --api-key YOUR_KEY + +# With OpenAI-compatible API +TEXT_API_KEY=your_key TEXT_BASE_URL=https://api.openai.com/v1 node scripts/translate-i18n.mjs --provider openai +``` + +### 4. Components Updated with i18n + +- ✅ CustomForm.tsx +- ✅ DialogueHistoryModal.tsx +- ✅ AuthModal.tsx +- ✅ PlayCanvas.tsx +- ✅ SettingsModal.tsx +- ✅ page.tsx (home page) +- ✅ layout.tsx (I18nProvider wrapper) +- ✅ LanguageSwitcher.tsx (new component) + +## Current Status + +### Completed + +1. **i18n Infrastructure** - All core files in `lib/i18n/` +2. **Translation Files** - zh-CN.ts (source) and en.ts (reference) complete +3. **Stub Files** - Created for all 20 target languages (fallback to en) +4. **Component Integration** - All UI components now use `t()` function +5. **Language Switcher** - Added to page header with dropdown UI +6. **TypeScript Types** - Full type safety for translation system + +### Remaining Work + +1. **Generate Actual Translations** + - Run the translation script to translate stub files + - Review and edit generated translations for quality + - Test with native speakers if possible + +2. **Update Metadata** (optional) + - Make page titles and descriptions dynamic based on locale + - Update `lang` attribute on html element dynamically + +### Optional Enhancements + +1. **Server-Side Rendering Support** + - Implement locale detection from Accept-Language header + - Add middleware for locale routing (e.g., /en/play, /zh-CN/play) + - Cache translations for better performance + +2. **Date/Number Formatting** + - Add locale-specific formatting for dates, numbers, currencies + - Use Intl.DateTimeFormat and Intl.NumberFormat + +3. **RTL Support** + - Currently no RTL locales, but infrastructure is in place + - Add layout mirroring if needed for future RTL languages + +4. **Pluralization** + - Enhance formatTranslation to support ICU message format + - Handle singular/plural forms + +## Translation Best Practices + +When adding new strings: + +1. Keep strings neutral where possible +2. Avoid culturally-specific references +3. Provide context for translators in comments +4. Test with longer strings (German, Russian can be 2-3x longer) +5. Keep placeholders consistent (`{{varName}}` or `{varName}`) + +## API Keys Required + +To generate translations, set one of: +- `GEMINI_API_KEY` for Google Gemini (recommended for cost) +- `TEXT_API_KEY` for OpenAI-compatible API +- `TEXT_BASE_URL` for custom OpenAI-compatible endpoints + +## Files Modified + +### New Files Created +- `lib/i18n/` (entire directory) + - `config.ts` - Locale configuration + - `client.tsx` - React context provider + - `server.ts` - Server-side utilities + - `utils.ts` - Helper functions + - `locales/zh-CN.ts` - Source translations + - `locales/en.ts` - English reference + - `locales/zh-TW.ts` - Traditional Chinese stub + - `locales/zh-HK.ts` - Hong Kong Chinese stub + - `locales/ja.ts` - Japanese stub + - `locales/ko.ts` - Korean stub + - `locales/es.ts` - Spanish stub + - `locales/fr.ts` - French stub + - `locales/de.ts` - German stub + - `locales/pt-BR.ts` - Portuguese Brazil stub + - `locales/pt.ts` - Portuguese stub + - `locales/ru.ts` - Russian stub + - `locales/it.ts` - Italian stub + - `locales/vi.ts` - Vietnamese stub + - `locales/th.ts` - Thai stub + - `locales/id.ts` - Indonesian stub + - `locales/tr.ts` - Turkish stub + - `locales/pl.ts` - Polish stub + - `locales/nl.ts` - Dutch stub + - `locales/uk.ts` - Ukrainian stub + - `locales/hi.ts` - Hindi stub + - `locales/cs.ts` - Czech stub +- `components/LanguageSwitcher.tsx` - Language selector component +- `scripts/translate-i18n.mjs` - Translation script +- `docs/i18n-implementation.md` - This documentation + +### Modified Files +- `app/layout.tsx` - Added I18nProvider wrapper +- `app/page.tsx` - Added i18n to all strings and LanguageSwitcher +- `components/CustomForm.tsx` - Added i18n +- `components/DialogueHistoryModal.tsx` - Added i18n +- `components/AuthModal.tsx` - Added i18n +- `components/PlayCanvas.tsx` - Added i18n +- `components/SettingsModal.tsx` - Added i18n + +## TypeScript + +All type definitions are in place. Run `pnpm typecheck` to verify. diff --git a/lib/engine/orchestrator.ts b/lib/engine/orchestrator.ts index 916a344..6f3cbee 100644 --- a/lib/engine/orchestrator.ts +++ b/lib/engine/orchestrator.ts @@ -64,6 +64,7 @@ export async function startSession( styleReferenceImage: req.styleReferenceImage?.trim() || undefined, orientation: coerceOrientation(req.orientation), playerName: req.playerName?.trim() || undefined, + language: req.language?.trim() || undefined, }; // Stage 0 — Architect (+ optional auto style selection, in parallel). diff --git a/lib/engine/prompts.ts b/lib/engine/prompts.ts index fcf6a74..344b858 100644 --- a/lib/engine/prompts.ts +++ b/lib/engine/prompts.ts @@ -9,6 +9,60 @@ import type { } from "@infiplot/types"; import { formatStepfunCatalogForPrompt } from "@infiplot/tts-client"; +// ══════════════════════════════════════════════════════════════════════ +// Output-language directive — appended to user messages so the AI's +// GENERATED dialogue, narration, and voice-design text follow the UI +// locale the player picked. Returns "" for zh-CN (the prompts' native +// language) so existing sessions behave byte-identically to before. +// +// We intentionally append this as a TRAILING one-liner rather than +// rewriting the system prompts in the target language — the prompts body +// is the cacheable / reviewed / future-edit-friendly asset, and a single +// trailing directive is enough for modern LLMs to switch their output +// language while still receiving Chinese instructions. +// ══════════════════════════════════════════════════════════════════════ +const LANG_LABELS: Record = { + "zh-CN": "简体中文", + "zh-TW": "繁體中文", + "zh-HK": "繁體中文(香港)", + en: "English", + ja: "日本語", + ko: "한국어", + es: "Español", + fr: "Français", + de: "Deutsch", + "pt-BR": "Português (Brasil)", + pt: "Português", + ru: "Русский", + it: "Italiano", + vi: "Tiếng Việt", + th: "ภาษาไทย", + id: "Bahasa Indonesia", + tr: "Türkçe", + pl: "Polski", + nl: "Nederlands", + uk: "Українська", + hi: "हिन्दी", + cs: "Čeština", +}; + +/** + * Returns a one-line Chinese instruction telling the LLM to produce its + * free-form output (dialogue, narration, voice-design text) in the player's + * selected UI language. Returns an empty string for zh-CN sessions — those + * are the prompts' native language and need no directive. + * + * Always returns Chinese regardless of session.language because the system + * prompts are Chinese; the directive instructs the model to *output* in the + * target language, not to read prompts in it. + */ +export function buildLanguageDirective(language: string | undefined): string { + if (!language || language === "zh-CN") return ""; + const label = LANG_LABELS[language]; + if (!label) return ""; + return `\n【输出语言】你产出的所有自然语言内容(对白台词 line / 旁白 narration / sceneSummary / storyState 各字段 / voiceDescription / lineDelivery 等)必须使用「${label}」;JSON 字段名、sceneKey、英文 visualDescription / painting prompt 仍按各 agent 既有规则。`; +} + // ══════════════════════════════════════════════════════════════════════ // Multi-agent scene generation pipeline: // Architect (总编剧) — ONE-TIME at session start: the story bible @@ -141,6 +195,8 @@ export function buildArchitectUserMessage(session: Session): string { parts.push( "\n请据此产出这部交互剧的故事档案(story bible),严格以 JSON 格式返回。", ); + const langDirective = buildLanguageDirective(session.language); + if (langDirective) parts.push(langDirective); return parts.join("\n"); } @@ -534,6 +590,8 @@ export function buildWriterPlanUserMessage(session: Session): string { parts.push( '\n现在**只规划本场景的骨架**(不要写 beats 台词):给出 sceneSummary(画面感强、含开场钩子)、sceneKey、entryBeatId、本场景会出场的全部角色 cast、以及入口 beat 的 entrySpeaker 与 entryActiveCharacters。严格以 JSON 格式返回。', ); + const langDirective = buildLanguageDirective(session.language); + if (langDirective) parts.push(langDirective); return parts.join("\n"); } @@ -577,6 +635,8 @@ export function buildWriterBeatsUserMessage( parts.push( "\n把上面的规划展开成完整的 beats[](入口 beat 用规划的 entryBeatId / speaker / 登场角色),写完后更新 storyStatePatch。严格以 JSON 格式返回。", ); + const langDirective = buildLanguageDirective(session.language); + if (langDirective) parts.push(langDirective); return parts.join("\n"); } @@ -737,6 +797,15 @@ export function buildCharacterDesignerUserMessage( parts.push( "\n请为该角色同时设计 visualDescription(英文,必须覆盖 system 中的 6 大要素清单)和 voiceDescription(中文),严格以 JSON 格式返回。", ); + // When the player picked a non-zh-CN UI language, override the + // system-prompt's "中文" voiceDescription guidance: the description text + // flows into MiMo's voice-design, which gives better prosody when the + // description is written in the target output language. (StepFun's 32 + // preset voices are fixed Chinese timbres, but voiceDescription is still + // used as documentation + stepfunVoiceId picking context — keeping it + // in the player's language is consistent.) + const langDirective = buildLanguageDirective(session.language); + if (langDirective) parts.push(langDirective); return parts.join("\n"); } @@ -1061,6 +1130,8 @@ export function buildInsertBeatUserMessage( parts.push(`\n玩家此刻的自由动作:${freeformAction}`); parts.push("\n请生成一个有实质回应的 beat,严格以 JSON 格式返回。"); + const langDirective = buildLanguageDirective(session.language); + if (langDirective) parts.push(langDirective); return parts.join("\n"); } diff --git a/lib/i18n/client.tsx b/lib/i18n/client.tsx new file mode 100644 index 0000000..ac90b90 --- /dev/null +++ b/lib/i18n/client.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { + createContext, + useContext, + useState, + useEffect, + type ReactNode, +} from "react"; +import type { Locale } from "./config"; +import { + DEFAULT_LOCALE, + LOCALE_STORAGE_KEY, + getInitialLocale, + setLocale as saveLocale, +} from "./config"; +import { getNestedValue, formatTranslation } from "./utils"; + +// Translation function type +export type TranslationFunction = ( + key: string, + params?: Record, +) => string; + +// Context type +interface I18nContextType { + locale: Locale; + setLocale: (locale: Locale) => void; + t: TranslationFunction; + // Returns an array of strings stored under the key (e.g. the typewriter + // example phrases). Falls back to the key wrapped in an array so callers + // can safely index. + tArray: (key: string) => string[]; +} + +const I18nContext = createContext(undefined); + +// Provider props +interface I18nProviderProps { + children: ReactNode; + initialLocale?: Locale; +} + +// Dynamic import of locale files + async function importLocale(locale: Locale) { + switch (locale) { + case "zh-CN": + return (await import("./locales/zh-CN")).zhCN; + case "en": + return (await import("./locales/en")).en; + case "zh-TW": + return (await import("./locales/zh-TW")).zhTW; + case "zh-HK": + return (await import("./locales/zh-HK")).zhHK; + case "ja": + return (await import("./locales/ja")).ja; + case "ko": + return (await import("./locales/ko")).ko; + case "es": + return (await import("./locales/es")).es; + case "fr": + return (await import("./locales/fr")).fr; + case "de": + return (await import("./locales/de")).de; + case "pt-BR": + return (await import("./locales/pt-BR")).ptBR; + case "pt": + return (await import("./locales/pt")).pt; + case "ru": + return (await import("./locales/ru")).ru; + case "it": + return (await import("./locales/it")).it; + case "vi": + return (await import("./locales/vi")).vi; + case "th": + return (await import("./locales/th")).th; + case "id": + return (await import("./locales/id")).id; + case "tr": + return (await import("./locales/tr")).tr; + case "pl": + return (await import("./locales/pl")).pl; + case "nl": + return (await import("./locales/nl")).nl; + case "uk": + return (await import("./locales/uk")).uk; + case "hi": + return (await import("./locales/hi")).hi; + case "cs": + return (await import("./locales/cs")).cs; + default: + console.warn(`Locale ${locale} not loaded, falling back to English`); + return (await import("./locales/en")).en; + } + } + +// Provider component +export function I18nProvider({ children, initialLocale }: I18nProviderProps) { + const [locale, setLocaleState] = useState(initialLocale ?? DEFAULT_LOCALE); + const [translations, setTranslations] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + + // Load translations when locale changes + useEffect(() => { + let cancelled = false; + + async function loadTranslations() { + setIsLoading(true); + try { + const localeData = await importLocale(locale); + if (!cancelled) { + setTranslations(localeData as Record); + setIsLoading(false); + } + } catch (error) { + console.error(`Failed to load translations for ${locale}:`, error); + if (!cancelled) { + // Fallback to default locale on error + if (locale !== DEFAULT_LOCALE) { + const fallback = await importLocale(DEFAULT_LOCALE); + setTranslations(fallback as Record); + } + setIsLoading(false); + } + } + } + + loadTranslations(); + + return () => { + cancelled = true; + }; + }, [locale]); + + // Keep in sync with the active locale for a11y / SEO. + useEffect(() => { + if (typeof document !== "undefined") { + document.documentElement.lang = locale; + } + }, [locale]); + + // Set locale function + const setLocale = (newLocale: Locale) => { + saveLocale(newLocale); + setLocaleState(newLocale); + }; + + // Translation function + const t: TranslationFunction = (key, params = {}) => { + if (isLoading) { + return key; // Return key during loading + } + + const value = getNestedValue(translations, key); + + if (value === undefined) { + console.warn(`Translation missing for key: ${key}`); + return key; + } + + if (typeof value === "function") { + return (value as (params: Record) => string)(params); + } + + if (typeof value === "string") { + return formatTranslation(value, params); + } + + return String(value); + }; + + const tArray: I18nContextType["tArray"] = (key) => { + if (isLoading) return []; + const value = getNestedValue(translations, key); + if (Array.isArray(value)) { + return value.map((v) => (typeof v === "string" ? v : String(v))); + } + if (value === undefined) { + console.warn(`Translation array missing for key: ${key}`); + } + return []; + }; + + return ( + + {children} + + ); +} + +// Hook to use i18n +export function useI18n() { + const context = useContext(I18nContext); + if (!context) { + throw new Error("useI18n must be used within I18nProvider"); + } + return context; +} + +// Hook to get just the translation function (for server-side or non-provider contexts) +export function useTranslation(locale?: Locale) { + const { t: clientT, locale: currentLocale } = useI18n(); + + return { + t: clientT, + locale: locale ?? currentLocale, + }; +} diff --git a/lib/i18n/config.ts b/lib/i18n/config.ts new file mode 100644 index 0000000..9654161 --- /dev/null +++ b/lib/i18n/config.ts @@ -0,0 +1,101 @@ +// Supported locales for InfiPlot +export const DEFAULT_LOCALE = "zh-CN" as const; + +export type Locale = + | "en" + | "zh-CN" + | "zh-TW" + | "zh-HK" + | "ja" + | "ko" + | "es" + | "fr" + | "de" + | "pt-BR" + | "pt" + | "ru" + | "it" + | "vi" + | "th" + | "id" + | "tr" + | "pl" + | "nl" + | "uk" + | "hi" + | "cs"; + +export const LOCALE_NAMES: Record = { + "en": "English", + "zh-CN": "简体中文", + "zh-TW": "繁體中文(台灣)", + "zh-HK": "繁體中文(香港)", + "ja": "日本語", + "ko": "한국어", + "es": "Español", + "fr": "Français", + "de": "Deutsch", + "pt-BR": "Português (Brasil)", + "pt": "Português", + "ru": "Русский", + "it": "Italiano", + "vi": "Tiếng Việt", + "th": "ภาษาไทย", + "id": "Bahasa Indonesia", + "tr": "Türkçe", + "pl": "Polski", + "nl": "Nederlands", + "uk": "Українська", + "hi": "हिन्दी", + "cs": "Čeština", +}; + +export const LOCALES: Locale[] = Object.keys(LOCALE_NAMES) as Locale[]; + +// Locale storage key +export const LOCALE_STORAGE_KEY = "infiplot:locale"; + +// Get locale from localStorage or browser language +export function getInitialLocale(): Locale { + if (typeof window === "undefined") return DEFAULT_LOCALE; + + try { + const stored = localStorage.getItem(LOCALE_STORAGE_KEY); + if (stored && LOCALES.includes(stored as Locale)) { + return stored as Locale; + } + } catch { + // ignore localStorage errors + } + + // Try to match browser language + const browserLang = navigator.language; + const exactMatch = LOCALES.find((l) => l === browserLang); + if (exactMatch) return exactMatch; + + // Try base language match (e.g., "zh" for "zh-TW") + const baseLang = browserLang.split("-")[0]; + if (baseLang) { + const baseMatch = LOCALES.find((l) => l.startsWith(baseLang)); + if (baseMatch) return baseMatch; + } + + return DEFAULT_LOCALE; +} + +// Save locale to localStorage +export function setLocale(locale: Locale): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(LOCALE_STORAGE_KEY, locale); + } catch { + // ignore localStorage errors + } +} + +// Get RTL locales (right-to-left languages) +export const RTL_LOCALES: Set = new Set(); + +export function isRTL(locale: Locale): boolean { + return RTL_LOCALES.has(locale); +} diff --git a/lib/i18n/index.ts b/lib/i18n/index.ts new file mode 100644 index 0000000..03668b9 --- /dev/null +++ b/lib/i18n/index.ts @@ -0,0 +1,15 @@ +// Main i18n exports +export * from "./config"; +export * from "./types"; +export * from "./utils"; +export { I18nProvider, useI18n, useTranslation } from "./client"; +export { + getLocaleFromHeaders, + loadTranslations, + getTranslations, + createTranslator, + getServerLocale, +} from "./server"; + +// Re-export locale types for convenience +export type { Locale, LOCALES, LOCALE_NAMES } from "./config"; diff --git a/lib/i18n/locales/cs.ts b/lib/i18n/locales/cs.ts new file mode 100644 index 0000000..d851b1d --- /dev/null +++ b/lib/i18n/locales/cs.ts @@ -0,0 +1,321 @@ +// Czech +// Auto-generated by scripts/translate-i18n.mjs + +export const cs = { + "layout": { + "metadata": { + "title": "InfiPlot — AI interaktivní hra příběhů", + "description": "InfiPlot je interaktivní hra příběhů, která používá AI k generování obsahu v reálném čase." + } + }, + "home": { + "examples": { + "male": [ + "Dětství přítelkyně se náhle zčervenal a přiznala mi lásku", + "Po probuzení se zdá, že všechny dívky ve třídě mě tajně milují", + "Uplynuly tři roky, ukázalo se, že jsem bohatý syn, čas na pomstu přišel", + "Vrátil jsem se s nekonečným Tokenem těsně před vznikem internetu..." + ], + "female": [ + "Přešla jsem do domu generála jako bezcenná dcera, ale chladný regent mě miluje jen mě", + "Vrátila jsem se noc před rozchodem, tentokrát já jsem odešla první", + "Probudila jsem se ve hře jako padouchova dcera, musím se vyhnout všem smrtícím koncům" + ], + "x": [ + "Otevřela se trhlina v čase, různé verze mě z různých světů se náhle objevily", + "V paláci paměti se zapomenuté fragmenty reformují do nového příběhu", + "Začala nekonečná hra, každý má jedinou šanci na úspěch", + "Systémové upozornění: Vaše volba rozhodne o osudu celého vesmíru" + ] + }, + "options": { + "gender": "Zaměření pohlaví", + "artStyle": "Umělecký styl", + "plotStyle": "Styl příběhu", + "voice": "Hlasové obsazení", + "pacing": "Tempo obsahu" + }, + "genders": { + "male": "Mužské", + "female": "Ženské", + "x": "X" + }, + "artStyles": { + "auto": "Automatické", + "custom": "Vlastní styl", + "kyoani": "Kyoto Animation", + "shinkai": "Makoto Shinkai", + "ghibli": "Studio Ghibli", + "3d": "3D animace", + "cyberpunk": "Kyberpunk", + "gothic": "Gotika", + "wasteland": "Poustevna", + "pixel": "Pixel art", + "realistic": "Realistické", + "oil": "Klasický olej", + "monet": "Claude Monet", + "watercolor": "Akvarel", + "ink": "Inkoust", + "ukiyoe": "Ukijo-e", + "pencil": "Barevná tužka", + "sketch": "Ruční skica", + "manga": "Černobílá manga", + "children": "Dětská kniha", + "crayon": "Dětská kresba", + "clay": "Hliněná plastika", + "dunhuang": "Dunhuangské nástěnné malby", + "miniature": "Miniatura", + "mosaic": "Mozaika", + "stainedGlass": "Skleněná mozaika", + "vaporwave": "Vaporwave", + "vector": "Vektorová ilustrace", + "lowpoly": "Nízký počet polygonů", + "popart": "Pop art", + "glitch": "Glitch art", + "papercut": "Paper cutting", + "steampunk": "Steampunk", + "xianxia": "Sien-šia", + "darkFairytale": "Tmavá pohádka", + "urbanFantasy": "Městská fantasy" + }, + "plotStyles": { + "straightforward": "Přímé a vzrušující", + "twist": "Vícezávitkové" + }, + "voiceOptions": { + "off": "Vypnuto", + "on": "Zapnuto" + }, + "pacings": { + "fast": "Rychlé a strhující", + "relaxed": "Pomalejší a detailní" + }, + "stories": { + "贤者陨落": "Pád mudrce", + "画中圣手": "Božská ruka v obraze", + "花魁的刀": "Meč courtesan" + }, + "ui": { + "start": "Start", + "loadStory": "Načíst příběh", + "settings": "Nastavení", + "searchPlaceholder": "Hledat styl...", + "noMatchingStyle": "Žádný odpovídající styl", + "close": "Zavřít", + "back": "Zpět", + "save": "Uložit", + "cancel": "Zrušit", + "saveAndSelect": "Uložit a vybrat" + }, + "styleModal": { + "title": "Vyberte umělecký styl", + "subtitle": "Výchozí 'Automatické' · AI automaticky odpovídající styl podle příběhu; 'Vlastní styl' umožňuje zadat popis nebo nahrát referenční obrázek", + "customTitle": "Vlastní styl", + "customPlaceholder": "Popište požadovaný styl obrazu, například:\nSnnová akvarelová stylizace, jemné tóny, nostalgická atmosféra\n\n💡 Tip: Některé modely lépe pracují s anglickými popisy, doporučuje použít AI nástroj k vytvoření profesionálního anglického popisu", + "uploadImage": "Nahrát referenční obrázek", + "changeImage": "Změnit", + "remove": "Odebrat", + "parsing": "Zpracování...", + "importFromPreset": "Importovat z přednastaveného stylu...", + "uploadError": "Podporovány jsou pouze obrazové soubory", + "visionError": "Vision model vrátil prázdný popis stylu", + "fileReadError": "Čtení souboru selhalo", + "imageDecodeError": "Dekódování obrazu selhalo", + "parseError": "Zpracování selhalo", + "refImageAlt": "Referenční obrázek stylu" + }, + "hero": { + "title": "Jaký příběh chcete dnes zažít?", + "placeholder": "Omlouváme se, ale nemohu splnit tento požadavek.", + "enterHint": "Enter k odeslání · Shift+Enter nový řádek" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? ' (během bety vyžadováno přihlášení, hra zdarma)' : ''; + return `Zadejte své nápady, nakonfigurujte styly a klikněte na "Spustit" pro hraní${authHint}. Můžete si také vybrat kurátorskou příběh z níže pro rychlé zážitky InfiPlot. Klikněte na "Nastavení" pro zadání vašeho jména a konfiguraci vlastních klíčů pro text, obrázky, vizi a TTS—vše uloženo lokálně ve vašem prohlížeči pro stabilnější zážitek.`; + }, + "closeAriaLabel": "Znovu nezobrazovat tuto nápovědu" + }, + "about": { + "title": "InfiPlot", + "description": "je interaktivní hra příběhů, která používá AI k generování obsahu v reálném čase — obrázky, zvuk a větve příběhu jsou generovány během hraní.", + "team": "TÝM", + "teamText": "Pocházíme z univerzit včetně Tsinghua University a Lanzhou University, chceme prozkoumat více možností multimodálních modelů mimo schopnosti jako 'přímé generování obrázků a videí'. Tento projekt je stále v rané fázi, stále rekrutujeme členy. Pokud máte zájem, kontaktujte nás, těšíme se na vás.", + "contact": "KONTAKT", + "email": "E-mail", + "openSource": "OTEVŘENÝ ZDROJ", + "betaUsers": "BETA TESTERI", + "qqGroupLabel": "Skupina QQ:", + "qqGroupAlt": "InfiPlot veřejná beta skupina QR kód (ID skupiny 575404333)", + "privacyPolicy": "Zásady ochrany soukromí", + "terms": "Podmínky služby", + "copyright": "© 2026 InfiPlot. Všechna práva vyhrazena." + }, + "errors": { + "emptyFile": "Tento soubor příběhu je prázdný.", + "fileTooLarge": "Soubor příběhu je příliš velký.", + "unpackFailed": "Rozbalení souboru příběhu selhalo.", + "parseFailed": "Zpracování souboru příběhu selhalo.", + "cardNotFound": "Curated story nebyl nalezen: {cardName}" + } + }, + "play": { + "loading": { + "firstFrame": "Načítání prvního scény", + "transitioning": "AI vytváří další scénu", + "visionThinking": "AI přemýšlí co jste viděli", + "loadingFirst": "První scény se načítá", + "awakening": "Načítání" + }, + "freeform": { + "placeholder": "Zadejte co chcete říct nebo udělat...", + "title": "Volný vstup", + "ariaLabel": "Volný vstup" + }, + "choiceDisabled": "Sdílený příběh neobsahuje tuto větev", + "tooltips": { + "openSettings": "Otevřít nastavení", + "openHistory": "Historie příběhu", + "fullscreen": "Režim celé obrazovky (F)", + "enterFullscreen": "Vstoupit do režimu celé obrazovky", + "exportGallery": "Exportovat jako interaktivní galerii", + "exportGalleryLabel": "Exportovat galerii", + "shareStory": "Exportovat příběh jako .infiplot", + "shareStoryLabel": "Sdílet aktuální příběh", + "mute": "Ztlumit", + "unmute": "Zrušit ztlumení", + "closeNudge": "Zavřít nápovědu", + "silenceNudge": "Nejste spokojeni? Zkuste zadat vlastní API klíč", + "back": "Zpět" + }, + "imageAlt": "Vygenerovaná scéna", + "counter": { + "scene": "Scéna {n}", + "beat": "Beat {n}", + "middle": "·" + }, + "buttons": { + "fullscreen": "F · klávesa · celá obrazovka", + "exportGallery": "Exportovat galerii", + "shareStory": "Sdílet příběh", + "muted": "Ztlumeno", + "sound": "Se zvukem" + }, + "error": { + "title": "Došlo k chybě", + "back": "Zpět" + }, + "previousStep": "Předchozí krok", + "settingsFooter": "Po uložení se TTS klíč okamžitě uplatní, použijte svůj limit k syntéze zvuku pro aktuální scénu.", + "shareErrors": { + "notFound": "Soubor příběhu nebyl nalezen.", + "invalid": "Sdílený soubor příběhu neobsahuje platný příběh.", + "noImage": "Sdílený soubor postrádá první obrázek scény.", + "noNextImage": "Sdílený soubor postrádá další obrázek scény.", + "noMemory": "Sdílený soubor postrádá počáteční paměť příběhu.", + "packFailed": "Balení sdíleného příběhu selhalo" + } + }, + "settings": { + "title": "Nastavení", + "subtitle": "Volitelné · Tato nastavení jsou uložena pouze v místním prohlížeči", + "tabs": { + "general": "Obecné", + "models": "Modely" + }, + "general": { + "playerName": "Jméno hráče", + "playerNamePlaceholder": "Prázdné použije 'vy'", + "playerNameHint": "NPC budou oslovoováni tímto jménem v dialogu.", + "visionClick": "Kliknutí pro identifikaci scény", + "visionOn": "Zapnuto", + "visionOff": "Vypnuto", + "visionHint": "Po zapnutí kliknutí na scénu ve výběrovém uzlu spustí AI identifikaci a vygeneruje novou větev příběhu." + }, + "models": { + "corsNotice": "Ujistěte se, že váš API endpoint podporuje CORS požadavky z prohlížeče. Většina hlavních poskytovatelů (OpenAI, Anthropic, Gemini, Runware atd.) již ve výchozím nastavení podporuje.", + "textModel": "Textový model", + "imageModel": "Obrazový model", + "visionModel": "Vision model", + "baseUrl": "Základní URL", + "apiKey": "API klíč", + "model": "Model", + "provider": "Poskytovatel (volitelné)", + "providerHint": "Při prázdném systém automaticky odvodí protokol podle základní URL.", + "providerAuto": "Automatické odvození (doporučeno)", + "show": "Zobrazit", + "hide": "Skrýt" + }, + "tts": { + "title": "Model hlasového doprovodu", + "description": 'Zadejte svůj Xiaomi MiMo API klíč, hlasový doprovod bude syntetizován lokálně v prohlížeči, klíč je uložen pouze lokálně. MiMo TTS je nynízdarma.', + "keyType": "Typ klíče", + "payg": "Pay-as-you-go", + "paygSub": "Začíná na sk-", + "tokenPlan": "Token plán", + "tokenPlanSub": "Začíná na tp-", + "region": "Regionální uzel", + "regionHint": "Vyberte uzel odpovídající vaší oblasti předplatného.", + "apiKeyPlaceholderPayg": "Vložte klíč začínající na sk-", + "apiKeyPlaceholderToken": "Vložte klíč začínající na tp-", + "keyMismatchPayg": "Tento klíč nezačíná na sk-", + "keyMismatchToken": "Tento klíč nezačíná na tp-", + "tutorialLink": "Jak získat klíč zdarma? Zobrazit tutoriál" + }, + "actions": { + "save": "Uložit", + "clearAll": "Vymazat vše" + } + }, + "auth": { + "steps": { + "pick": "Přihlaste se pro pokračování", + "email": "Přihlášení e-mailem", + "otp": "Ověřovací kód" + }, + "googleLogin": "Přihlášení Google", + "githubLogin": "Přihlášení GitHub", + "emailLogin": "Přihlášení ověřovacím kódem e-mailem", + "or": "Nebo", + "emailPlaceholder": "your@email.com", + "sendCode": "Odeslat kód", + "sending": "Odesílání...", + "codeSent": "Ověřovací kód byl odeslán na {email}", + "codePlaceholder": "6místný ověřovací kód", + "verify": "Potvrdit", + "verifying": "Ověřování...", + "resend": "Znovu odeslat", + "back": "Zpět", + "close": "Zavřít", + "ariaLabel": "Přihlášení" + }, + "history": { + "title": "Historie příběhu", + "close": "Zavřít", + "closeAriaLabel": "Zavřít historii příběhu", + "noHistory": "Zatím žádná historie.", + "scene": "Scéna {n}", + "choice": "Volba", + "action": "Akce", + "ariaLabel": "Historie příběhu" + }, + "customForm": { + "world": "Svět · Světový názor", + "style": "Styl · Vizuální styl", + "worldPlaceholder": "Příklad: Jihočínský okresální město koncem 90. let. Hlavní postava je přestupující student v posledním ročníku střední školy, který v deštivém červnu potkává spolužáka, který čte básně na střeše.", + "stylePlaceholder": "Příklad: Akvarelové měkké světlo, teplé odpolední světlo, styl vizuální novely, tradiční panel dialogu...", + "status": { + "ready": "Připraven", + "needMore": "Je třeba ještě dva odstavce", + "starting": "První scény se načítá..." + }, + "start": "Spustit" + }, + "language": { + "title": "Jazyk", + "current": "Aktuální jazyk", + "select": "Vybrat jazyk" + } +} as const; + +export type csTranslations = typeof cs; diff --git a/lib/i18n/locales/de.ts b/lib/i18n/locales/de.ts new file mode 100644 index 0000000..30e9b5e --- /dev/null +++ b/lib/i18n/locales/de.ts @@ -0,0 +1,89 @@ +// German (Germany) +// Auto-generated by scripts/translate-i18n.mjs + +export const de = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? ' (Anmeldung während der Beta erforderlich, kostenloses Spielen)' : ''; + return `Gib deine Ideen ein, konfiguriere Stile und klicke auf "Starten" zum Spielen${authHint}. Du kannst auch eine kuratierte Geschichte unten auswählen, um InfiPlot schnell zu erleben. Klicke auf "Einstellungen", um deinen Namen einzugeben und deine eigenen Text-, Bild-, Vision- und TTS-Schlüssel zu konfigurieren—alles wird lokal in deinem Browser für eine stabilere Erfahrung gespeichert.`; + }, + "closeAriaLabel": "Diesen Hinweis nicht mehr anzeigen" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type DeTranslations = typeof de; diff --git a/lib/i18n/locales/en.ts b/lib/i18n/locales/en.ts new file mode 100644 index 0000000..98797c4 --- /dev/null +++ b/lib/i18n/locales/en.ts @@ -0,0 +1,387 @@ +// English (en) - Base English translations +// This is a manually translated reference file + +export const en = { + // ========== Layout ========== + layout: { + metadata: { + title: "InfiPlot — AI Real-time Interactive Story Game", + description: "InfiPlot is an interactive story game demo that uses AI to generate images, voice, and story branches in real-time.", + }, + }, + + // ========== Home Page ========== + home: { + examples: { + male: [ + "My childhood friend suddenly blushes and confesses her feelings to me", + "I wake up one day and find that all the girls in my class seem to have secretly fallen in love with me", + "The three-year deadline has arrived. Turns out I'm a wealthy heir, and the time for revenge is now", + "I travel back to the eve of the internet's birth with unlimited tokens...", + ], + female: [ + "Transmigrated as the useless daughter of a general's mansion, the cold regent only dotes on me", + "Reborn on the night before our breakup, this time I'll be the one to let go first", + "I wake up as a villainess in an otome game and must avoid all death endings", + ], + x: [ + "The spacetime rift opens, and versions of myself from parallel worlds suddenly appear", + "In the memory palace, forgotten fragments are reassembling into a new story", + "An infinite flow game begins—everyone has only one chance to clear it", + "System notification: your choice will determine the fate of the entire universe", + ], + }, + + options: { + gender: "Orientation", + artStyle: "Art Style", + plotStyle: "Plot Style", + voice: "Voice", + pacing: "Pacing", + }, + + genders: { + male: "Male-oriented", + female: "Female-oriented", + x: "Universal", + }, + + artStyles: { + auto: "Auto", + custom: "Custom Style", + kyoani: "Kyoto Animation", + shinkai: "Makoto Shinkai", + ghibli: "Studio Ghibli", + "3d": "3D Animation", + cyberpunk: "Cyberpunk", + gothic: "Gothic", + wasteland: "Wasteland", + pixel: "Pixel Art", + realistic: "Realistic", + oil: "Classical Oil", + monet: "Monet", + watercolor: "Watercolor", + ink: "Ink Wash", + ukiyoe: "Ukiyo-e", + pencil: "Colored Pencil", + sketch: "Hand-drawn Sketch", + manga: "Black & White Manga", + children: "Children's Picture Book", + crayon: "Crayon Drawing", + clay: "Clay Art", + dunhuang: "Dunhuang Mural", + miniature: "Miniature", + mosaic: "Mosaic", + stainedGlass: "Stained Glass", + vaporwave: "Vaporwave", + vector: "Vector Art", + lowpoly: "Low Poly", + popart: "Pop Art", + glitch: "Glitch Art", + papercut: "Papercut Art", + steampunk: "Steampunk", + xianxia: "Xianxia Fantasy", + darkFairytale: "Dark Fairytale", + urbanFantasy: "Urban Fantasy", + }, + + plotStyles: { + straightforward: "Linear", + twist: "Multi-branch", + suspense: "Suspenseful", + healing: "Slice-of-life", + }, + + voiceOptions: { + off: "Off", + on: "On", + }, + + pacings: { + slow: "Slow-burn", + fast: "Brisk", + }, + + stories: { + sage_downfall: "Sage's Downfall", + brush_sage: "Painter Sage", + courtesan_blade: "Courtesan's Blade", + }, + + ui: { + start: "Start", + loadStory: "Load Story", + settings: "Settings", + searchPlaceholder: "Search styles…", + noMatchingStyle: "No matching styles", + close: "Close", + back: "Back", + save: "Save", + cancel: "Cancel", + saveAndSelect: "Save and Select", + }, + + styleModal: { + title: "Select Art Style", + subtitle: 'Default "Auto" · AI automatically matches the style to your story; select "Custom Style" to enter a description or upload a reference image', + customTitle: "Custom Style", + customPlaceholder: `Describe the visual style you want, for example: +Dreamy watercolor style with soft tones and nostalgic atmosphere + +💡 Tip: Some image models work better with English prompts. Consider using an AI chatbot to generate professional English style descriptions first, then paste them here.`, + uploadImage: "Upload Reference", + changeImage: "Change Image", + remove: "Remove", + parsing: "Parsing…", + importFromPreset: "Import from Preset…", + uploadError: "Only image files are supported", + visionError: "Vision model returned an empty style description", + fileReadError: "Failed to read file", + imageDecodeError: "Failed to decode image", + parseError: "Failed to parse", + refImageAlt: "Style reference image", + }, + + hero: { + title: "What story do you want to experience today?", + placeholder: " ", + enterHint: "Enter to send · Shift+Enter for newline", + }, + + hint: { + text: (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? ' (login required during beta, free to play)' : ''; + return `Enter your ideas, configure styles, and click "Start" to play${authHint}. You can also pick a curated story from below to quickly experience InfiPlot. Click "Settings" to enter your name and configure your own text, image, vision models and TTS keys—all stored locally in your browser for a more stable experience.`; + }, + closeAriaLabel: "Don't show this hint again", + }, + + about: { + title: "InfiPlot", + description: "is an interactive story game that uses AI to generate content in real-time — images, voice, and story branches are all generated during gameplay.", + team: "TEAM", + teamText: "We are from universities including Tsinghua University and Lanzhou University, hoping to explore more possibilities of multimodal models beyond oneshot capabilities like direct image and video generation. This project is still in its early stages, and we are recruiting. If you're interested, please contact us—we look forward to your joining.", + contact: "CONTACT", + email: "Email", + openSource: "OPEN SOURCE", + betaUsers: "BETA USERS", + qqGroupLabel: "QQ Group: ", + qqGroupAlt: "InfiPlot Public Beta Group QR Code (Group ID: 575404333)", + legalNotice: (params: { analyticsOn?: boolean }) => { + const base = "During public beta, this product is free to use but stability may vary with concurrent user load.
Content generated during public beta is not saved on servers. To preserve your experience, use the export gallery or story sharing features after playing.
AI-generated content does not represent our team's stance."; + if (params.analyticsOn) { + return `${base}
This site uses open-source
Umami for privacy-friendly anonymous analytics: no cookies, no personal data collection, no transmission of your inputs, no cross-site tracking.`; + } + return base; + }, + privacyPolicy: "Privacy Policy", + terms: "Terms of Service", + copyright: "© 2026 InfiPlot. All rights reserved.", + }, + + errors: { + emptyFile: "This story file is empty.", + fileTooLarge: "The story file is too large to load.", + unpackFailed: "Failed to unpack the story file.", + parseFailed: "Failed to parse the story file.", + cardNotFound: "Curated story not found: {cardName}", + }, + }, + + // ========== Play Page ========== + // NOTE: zh-CN uses " · " between every character as a stylistic effect. + // Other locales MUST NOT use this dot separator — just plain words. + play: { + loading: { + firstFrame: "Drawing the first scene", + transitioning: "AI is painting the next scene", + visionThinking: "AI is interpreting what you see", + loadingFirst: "Awakening the first scene", + awakening: "Loading", + }, + + freeform: { + placeholder: "Enter what you want to say or do...", + title: "Free Input", + ariaLabel: "Free input", + }, + + choiceDisabled: "This branch is not included in the shared story", + + tooltips: { + openSettings: "Open Settings", + openHistory: "Story History", + fullscreen: "Fullscreen (F)", + enterFullscreen: "Enter Fullscreen", + exportGallery: "Export current session as interactive gallery link (with voice; keeps only the 2 most recent gallery links)", + exportGalleryLabel: "Export Interactive Gallery", + shareStory: "Export current session as playable .infiplot story file (with voice)", + shareStoryLabel: "Share Current Story", + mute: "Mute", + unmute: "Unmute", + closeNudge: "Close hint", + silenceNudge: "Poor quality/often silent? Try entering your own API Key", + back: "Back", + }, + + imageAlt: "Generated scene", + + counter: { + scene: "Scene {n}", + beat: "Frame {n}", + middle: " ", + }, + + buttons: { + fullscreen: "Fullscreen", + exportGallery: "Export Gallery", + shareStory: "Share Story", + muted: "Muted", + sound: "Sound", + }, + + error: { + title: "Something went wrong", + back: "Back", + }, + + previousStep: "Previous action", + + settingsFooter: "After saving, the voice key takes effect immediately and uses your quota to synthesize voice for the current scene.", + + shareErrors: { + notFound: "No story file found to load.", + invalid: "Story share file has no playable content.", + noImage: "Story share file is missing the first scene image.", + noNextImage: "Story share file is missing the next scene image.", + noMemory: "Story share file is missing initial story memory and cannot be loaded.", + packFailed: "Failed to pack story share", + }, + + exportProgress: { + preparingVoice: "Preparing voice", + }, + }, + + // ========== Settings Modal ========== + settings: { + title: "Settings", + subtitle: "Optional · These settings are saved only in your local browser", + + tabs: { + general: "General", + models: "Models", + }, + + general: { + playerName: "Player Name", + playerNamePlaceholder: "Leave empty to use 'You'", + playerNameHint: "NPCs will address you by this name in dialogue. If left empty, 'You' will be used by default.", + visionClick: "Click Image Recognition", + visionOn: "On", + visionOff: "Off", + visionHint: "When enabled, clicking on the image at choice nodes will trigger AI vision recognition and generate new story branches.", + }, + + models: { + corsNotice: "Please ensure your API endpoint supports browser CORS requests. Most mainstream providers (OpenAI, Anthropic, Gemini, Runware, etc.) support this by default.", + textModel: "Text Model", + imageModel: "Image Model", + visionModel: "Vision Model", + baseUrl: "BASE URL", + apiKey: "API Key", + model: "Model", + provider: "Provider (Optional)", + providerHint: "Leave empty for the system to auto-detect the protocol based on the Base URL.", + providerAuto: "Auto-detect (Recommended)", + show: "Show", + hide: "Hide", + }, + + tts: { + title: "Voice Model", + description: 'Enter your own Xiaomi MiMo API Key. Voice synthesis runs locally in your browser, and the key is saved locally and never sent to the server. MiMo TTS is currently free for a limited time—just apply to use it.', + keyType: "Key Type", + payg: "Pay-as-you-go", + paygSub: "Starts with sk-", + tokenPlan: "Token Plan", + tokenPlanSub: "Starts with tp-", + region: "Region Node", + regionHint: "Select the node matching your subscription region (usually the one with lowest latency).", + apiKeyPlaceholderPayg: "Paste sk- pay-as-you-go key", + apiKeyPlaceholderToken: "Paste tp- token plan key", + keyMismatchPayg: 'This key does not start with sk-. It may not match the selected "Pay-as-you-go" type. Please check if you entered it correctly.', + keyMismatchToken: 'This key does not start with tp-. It may not match the selected "Token Plan" type. Please check if you entered it correctly.', + tutorialLink: "How to get a free key? View tutorial", + }, + + actions: { + save: "Save", + clearAll: "Clear All", + }, + }, + + // ========== Auth Modal ========== + auth: { + steps: { + pick: "Login to Continue", + email: "Email Login", + otp: "Verification Code", + }, + + googleLogin: "Continue with Google", + githubLogin: "Continue with GitHub", + emailLogin: "Email Verification Code", + or: "or", + + emailPlaceholder: "your@email.com", + sendCode: "Send Code", + sending: "Sending...", + + codeSent: "Verification code sent to {email}", + codePlaceholder: "6-digit code", + verify: "Confirm", + verifying: "Verifying...", + resend: "Resend", + + back: "Back", + + close: "Close", + ariaLabel: "Login", + }, + + // ========== Dialogue History Modal ========== + history: { + title: "Story History", + close: "Close", + closeAriaLabel: "Close story history", + noHistory: "No history yet.", + scene: "Scene {n}", + choice: "Choice", + action: "Action", + ariaLabel: "Story history", + }, + + // ========== Custom Form ========== + customForm: { + world: "World", + style: "Style", + worldPlaceholder: "Example: A small county town in southern China in the late 1990s. The protagonist is a transfer student in senior year who meets a classmate always reading poetry on the rooftop during the rainy June. Slow-burn, subtle, slightly melancholic...", + stylePlaceholder: "Example: Watercolor soft light, afternoon warmth, anime visual novel style, traditional dialogue panel...", + status: { + ready: "Ready", + needMore: "Two more to go", + starting: "Waking first frame…", + }, + start: "Start", + }, + + // ========== Language Switcher ========== + language: { + title: "Language", + current: "Current Language", + select: "Select Language", + }, +} as const; + +export type EnTranslations = typeof en; diff --git a/lib/i18n/locales/es.ts b/lib/i18n/locales/es.ts new file mode 100644 index 0000000..53c3e9e --- /dev/null +++ b/lib/i18n/locales/es.ts @@ -0,0 +1,89 @@ +// Spanish +// Auto-generated by scripts/translate-i18n.mjs + +export const es = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? ' (se requiere inicio de sesión durante la beta, juego gratuito)' : ''; + return `Ingresa tus ideas, configura estilos y haz clic en "Iniciar" para jugar${authHint}. También puedes elegir una historia curada de abajo para experimentar rápidamente InfiPlot. Haz clic en "Configuración" para ingresar tu nombre y configurar tus propias claves de texto, imagen, visión y TTS—todo almacenado localmente en tu navegador para una experiencia más estable.`; + }, + "closeAriaLabel": "No volver a mostrar este consejo" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type EsTranslations = typeof es; diff --git a/lib/i18n/locales/fr.ts b/lib/i18n/locales/fr.ts new file mode 100644 index 0000000..78025a7 --- /dev/null +++ b/lib/i18n/locales/fr.ts @@ -0,0 +1,89 @@ +// French +// Auto-generated by scripts/translate-i18n.mjs + +export const fr = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? ' (connexion requise pendant la bêta, jeu gratuit)' : ''; + return `Entrez vos idées, configurez les styles et cliquez sur "Démarrer" pour jouer${authHint}. Vous pouvez également choisir une histoire sélectionnée ci-dessous pour découvrir rapidement InfiPlot. Cliquez sur "Paramètres" pour entrer votre nom et configurer vos propres clés de texte, d'image, de vision et de TTS—tout est stocké localement dans votre navigateur pour une expérience plus stable.`; + }, + "closeAriaLabel": "Ne plus afficher cette astuce" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type FrTranslations = typeof fr; diff --git a/lib/i18n/locales/hi.ts b/lib/i18n/locales/hi.ts new file mode 100644 index 0000000..64a68b8 --- /dev/null +++ b/lib/i18n/locales/hi.ts @@ -0,0 +1,321 @@ +// Hindi +// Auto-generated by scripts/translate-i18n.mjs + +export const hi = { + "layout": { + "metadata": { + "title": "InfiPlot — AI रीयल-टाइम इंटरैक्टिव स्टोरी गेम", + "description": "InfiPlot एक इंटरैक्टिव स्टोरी गेम है जो AI का उपयोग करके रीयल-टाइम में सामग्री उत्पन्न करता है।" + } + }, + "home": { + "examples": { + "male": [ + "बचपन की सहेली ने अचानक शर्माते हुए मुझसे प्यार का इज़हार किया", + "एक नींद के बाद जागने पर लगा कि कक्षा की सभी लड़कियां चुपके से मुझसे प्यार करने लगी हैं", + "तीन साल की अवधि समाप्त, अब पता चला मैं एक अमीर परिवार का बेटा हूं, बदला लेने का समय आ गया है", + "मैं अनंत टोकन लेकर इंटरनेट के जन्म से ठीक पहले वापस आ गया हूं..." + ], + "female": [ + "जनरल के घर की बेकार बेटी में बदल गई, लेकिन ठंडे राजकुमार ने केवल मुझे चाहा", + "संबंध-विच्छेद से एक रात पहले वापस आ गई, इस बार मैंने पहले हाथ उठाए", + "एक खेल में खलनायिका की बेटी बन गई, सभी मृत्यु अंत से बचना है" + ], + "x": [ + "समय-स्थान विदर में खुल गया, कई समानांतर दुनिया के स्वयं अचानक सामने आ गए", + "स्मृति महल में, वे भूले हुए टुकड़े नई कहानी में पुनर्गठित हो रहे हैं", + "एक अनंत खेल शुरू हो गया, सभी के पास एक अनूठा मौका है", + "सिस्टम संकेत: आपकी पसंद पूरे ब्रह्मांड के भाग्य को निर्धारित करेगी" + ] + }, + "options": { + "gender": "लिंग झुकाव", + "artStyle": "कला शैली", + "plotStyle": "कथा शैली", + "voice": "आवाज डबिंग", + "pacing": "गति" + }, + "genders": { + "male": "पुरुष-ओरिएंटेड", + "female": "महिला-ओरिएंटेड", + "x": "X" + }, + "artStyles": { + "auto": "स्वचालित", + "custom": "कस्टम शैली", + "kyoani": "क्योटो एनीमेशन", + "shinkai": "माकोतो शिंकाई", + "ghibli": "घिबली स्टूडियो", + "3d": "3D एनीमेशन", + "cyberpunk": "साइबरपंक", + "gothic": "गॉथिक", + "wasteland": "बंजर भूमि", + "pixel": "पिक्सेल आर्ट", + "realistic": "यथार्थवादी", + "oil": "शास्त्रीय तेल चित्र", + "monet": "क्लाउद मोने", + "watercolor": "जल रंग", + "ink": "स्याही चित्र", + "ukiyoe": "उकियो-ए", + "pencil": "रंगीन पेंसिल", + "sketch": "हाथ से बनाया गया स्केच", + "manga": "श्वेत-श्याम मंगा", + "children": "बाल साहित्य", + "crayon": "बच्चों की क्रेयन चित्र", + "clay": "मिट्टी की कला", + "dunhuang": "दुनहुआंग दीवार चित्र", + "miniature": "लघु चित्र", + "mosaic": "मोज़ेक", + "stainedGlass": "दाग़ीन कांच", + "vaporwave": "वेपरवेव", + "vector": "वेक्टर चित्र", + "lowpoly": "कम पोलीगॉन", + "popart": "पॉप आर्ट", + "glitch": "ग्लिच आर्ट", + "papercut": "कागज़ काटना कला", + "steampunk": "स्टीमपंक", + "xianxia": "सियानशिया", + "darkFairytale": "अंधेरी परी कथा", + "urbanFantasy": "शहरी कल्पना" + }, + "plotStyles": { + "straightforward": "सीधी रोमांचक", + "twist": "बहु-मोड़ी रोमांचक" + }, + "voiceOptions": { + "off": "बंद", + "on": "चालू" + }, + "pacings": { + "fast": "तेज़ और रोमांचक", + "relaxed": "धीरे और विस्तृत" + }, + "stories": { + "贤者陨落": "ऋषि का पतन", + "画中圣手": "चित्र में दिव्य हाथ", + "花魁的刀": "वेश्या की तलवार" + }, + "ui": { + "start": "शुरू", + "loadStory": "कहानी लोड करें", + "settings": "सेटिंग्स", + "searchPlaceholder": "शैली खोजें...", + "noMatchingStyle": "कोई मेल खाने वाली शैली नहीं", + "close": "बंद करें", + "back": "वापस", + "save": "सहेजें", + "cancel": "रद्द करें", + "saveAndSelect": "सहेजें और चुनें" + }, + "styleModal": { + "title": "कला शैली चुनें", + "subtitle": "डिफ़ॉल्ट 'स्वचालित' · AI कहानी के अनुसार शैली स्वचालित रूप से मिलाता है; 'कस्टम शैली' चुनकर आप विवरण दे सकते हैं या संदर्भ चित्र अपलोड कर सकते हैं", + "customTitle": "कस्टम शैली", + "customPlaceholder": "अपनी इच्छित शैली का वर्णन करें, उदाहरण के लिए:\nस्वप्निल जल रंग शैली, कोमल रंग, पुरानी यादें\n\n💡 संकेत: कुछ ड्रॉइंग मॉडल के लिए अंग्रेजी संकेत शब्द बेहतर काम करते हैं, एआई वार्ता टूल का उपयोग करके पेशेवर अंग्रेजी शैली विवरण उत्पन्न करने का सुझाव दिया जाता है", + "uploadImage": "संदर्भ चित्र अपलोड करें", + "changeImage": "बदलें", + "remove": "हटाएं", + "parsing": "विश्लेषण हो रहा है...", + "importFromPreset": "प्रीसेट शैली से आयात करें...", + "uploadError": "केवल चित्र फ़ाइल समर्थित है", + "visionError": "दृश्य मॉडल ने खाली शैली विवरण लौटाया", + "fileReadError": "फ़ाइल पढ़ने में विफल", + "imageDecodeError": "चित्र को डिकोड करने में विफल", + "parseError": "विश्लेषण में विफल", + "refImageAlt": "शैली संदर्भ चित्र" + }, + "hero": { + "title": "आज कौन सी कहानी का अनुभव करना चाहते हैं?", + "placeholder": "माफ़ कीजिए, मैं उस अनुरोध को पूरा नहीं कर सकता।", + "enterHint": "एंटर भेजें · शिफ्ट+एंटर नई पंक्ति" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? ' (बीटा के दौरान लॉगिन आवश्यक, मुफ्त खेल)' : ''; + return `अपने विचार दर्ज करें, शैलियों को कॉन्फ़िगर करें और खेलने के लिए "शुरू" क्लिक करें${authHint}। आप नीचे से एक क्यूरेटेड कहानी चुनकर InfiPlot का तेजी से अनुभव भी कर सकते हैं। "सेटिंग्स" पर क्लिक करें अपना नाम दर्ज करने और अपनी टेक्स्ट, इमेज, विजन और TTS कुंजियों को कॉन्फ़िगर करने के लिए—सब कुछ अधिक स्थिर अनुभव के लिए आपके ब्राउज़र में स्थानीय रूप से संग्रहीत है।`; + }, + "closeAriaLabel": "यह संकेत फिर न दिखाएं" + }, + "about": { + "title": "InfiPlot", + "description": "एक इंटरैक्टिव स्टोरी गेम है जो AI का उपयोग करके रीयल-टाइम में सामग्री उत्पन्न करता है — चित्र, आवाज और कथा शाखाएं खेल के दौरान तुरंत उत्पन्न होती हैं।", + "team": "टीम", + "teamText": "हम सिंघुआ विश्वविद्यालय, लांज़ू विश्वविद्यालय और अन्य संस्थानों से आते हैं, और हम बहु-मोडल मॉडल की संभावनाओं का पता लगाना चाहते हैं। यह परियोजना अभी प्रारंभिक चरण में है, हम अभी भी सदस्यों की तलाश में हैं। यदि आप भी रुचि रखते हैं, तो कृपया संपर्क करें, हम आपके शामिल होने की प्रतीक्षा करते हैं।", + "contact": "संपर्क", + "email": "ईमेल", + "openSource": "ओपन सोर्स पता", + "betaUsers": "बीटा उपयोगकर्ता समूह", + "qqGroupLabel": "QQ समूह नंबर:", + "qqGroupAlt": "InfiPlot सार्वजनिक बीटा समूह QR कोड (समूह नंबर 575404333)", + "privacyPolicy": "गोपनीयता नीति", + "terms": "सेवा की शर्तें", + "copyright": "© 2026 InfiPlot. सर्वाधिकार सुरक्षित।" + }, + "errors": { + "emptyFile": "यह कहानी फ़ाइल खाली है।", + "fileTooLarge": "कहानी फ़ाइल बहुत बड़ी है, लोड नहीं हो सकती।", + "unpackFailed": "कहानी फ़ाइल अनपैक करने में विफल।", + "parseFailed": "कहानी फ़ाइल पार्स करने में विफल।", + "cardNotFound": "चयनित कहानी नहीं मिली: {cardName}" + } + }, + "play": { + "loading": { + "firstFrame": "प्रथम दृश्य बन रहा है", + "transitioning": "AI अगला दृश्य बना रहा है", + "visionThinking": "AI सोच रहा है आपने क्या देखा", + "loadingFirst": "पहला दृश्य लोड हो रहा है", + "awakening": "लोड हो रहा है" + }, + "freeform": { + "placeholder": "आप जो कहना या करना चाहते हैं वह टाइप करें...", + "title": "स्वतंत्र इनपुट", + "ariaLabel": "स्वतंत्र इनपुट" + }, + "choiceDisabled": "साझा कहानी में यह शाखा शामिल नहीं है", + "tooltips": { + "openSettings": "सेटिंग्स खोलें", + "openHistory": "कहानी इतिहास", + "fullscreen": "फुलस्क्रीन (F)", + "enterFullscreen": "फुलस्क्रीन में प्रवेश करें", + "exportGallery": "इंटरैक्टिव गैलरी लिंक के रूप में निर्यात करें", + "exportGalleryLabel": "इंटरैक्टिव गैलरी निर्यात करें", + "shareStory": "चालू कहानी .infiplot के रूप में निर्यात करें", + "shareStoryLabel": "वर्तमान कहानी साझा करें", + "mute": "मूक", + "unmute": "आवाज़ चालू", + "closeNudge": "संकेत बंद करें", + "silenceNudge": "प्रभाव संतोषजनक नहीं/अक्सर कोई आवाज़ नहीं? अपना API कुंजी आज़माएं", + "back": "वापस" + }, + "imageAlt": "उत्पन्न दृश्य", + "counter": { + "scene": "दृश्य {n}", + "beat": "बीट {n}", + "middle": "·" + }, + "buttons": { + "fullscreen": "F · कुंजी · फुलस्क्रीन", + "exportGallery": "गैलरी · निर्यात", + "shareStory": "कहानी · साझा", + "muted": "मूक", + "sound": "आवाज़" + }, + "error": { + "title": "कुछ समस्या आई", + "back": "वापस" + }, + "previousStep": "पिछला चरण", + "settingsFooter": "सहेजने के बाद TTS कुंजी तुरंत प्रभावी होगी, अपने कोटे से वर्तमान दृश्य की आवाज़ बनाएं।", + "shareErrors": { + "notFound": "लोड करने के लिए कोई कहानी फ़ाइल नहीं मिली।", + "invalid": "कहानी साझा फ़ाइल में कोई लोड करने योग्य कहानी नहीं है।", + "noImage": "कहानी साझा फ़ाइल में पहला दृश्य चित्र नहीं है।", + "noNextImage": "कहानी साझा फ़ाइल में अगला दृश्य चित्र नहीं है।", + "noMemory": "कहानी साझा फ़ाइल में प्रारंभिक कहानी स्मृति नहीं है।", + "packFailed": "कहानी साझा पैकेजिंग विफल" + } + }, + "settings": { + "title": "सेटिंग्स", + "subtitle": "वैकल्पिक · ये सेटिंग्स केवल स्थानीय ब्राउज़र में सहेजी जाती हैं", + "tabs": { + "general": "सामान्य", + "models": "मॉडल" + }, + "general": { + "playerName": "खिलाड़ी का नाम", + "playerNamePlaceholder": "खाली छोड़ने पर 'आप' का उपयोग होगा", + "playerNameHint": "NPC बातचीत में इस नाम से संबोधित करेंगे।", + "visionClick": "दृश्य पर क्लिक पहचान", + "visionOn": "चालू", + "visionOff": "बंद", + "visionHint": "चालू करने पर, चयन नोड पर दृश्य क्लिक करने से AI दृश्य पहचान और नई कहानी शाखा उत्पन्न होगी।" + }, + "models": { + "corsNotice": "सुनिश्चित करें कि आपका API एंडपॉइंट ब्राउज़र CORS अनुरोध का समर्थन करता है। अधिकांश प्रमुख प्रदाता (OpenAI, Anthropic, Gemini, Runware आदि) पहले से समर्थन करते हैं।", + "textModel": "पाठ मॉडल", + "imageModel": "चित्र मॉडल", + "visionModel": "दृश्य मॉडल", + "baseUrl": "आधार URL", + "apiKey": "API कुंजी", + "model": "मॉडल", + "provider": "प्रदाता (वैकल्पिक)", + "providerHint": "खाली छोड़ने पर सिस्टम आधार URL से स्वचालित रूप से प्रोटोकॉल निर्धारित करेगा।", + "providerAuto": "स्वचालित अनुमान (अनुशंसित)", + "show": "दिखाएं", + "hide": "छुपाएं" + }, + "tts": { + "title": "आवाज़ डबिंग मॉडल", + "description": 'अपना शाओमी MiMo API कुंजी भरें, डबिंग ब्राउज़र में स्थानीय रूप से संश्लेषित होगी, कुंजी केवल स्थानीय रूप से सहेजी जाती है। MiMo TTS वर्तमान मेंमुफ्त है।', + "keyType": "कुंजी प्रकार", + "payg": "भुगतान-जैसा-आप-उपयोग-करें", + "paygSub": "sk- से शुरू", + "tokenPlan": "टोकन योजना", + "tokenPlanSub": "tp- से शुरू", + "region": "क्षेत्र नोड", + "regionHint": "अपनी योजना सदस्यता क्षेत्र के साथ मेल खाता नोड चुनें।", + "apiKeyPlaceholderPayg": "sk- से शुरू होने वाली कुंजी चिपकाएं", + "apiKeyPlaceholderToken": "tp- से शुरू होने वाली कुंजी चिपकाएं", + "keyMismatchPayg": "यह कुंजी sk- से शुरू नहीं होती", + "keyMismatchToken": "यह कुंजी tp- से शुरू नहीं होती", + "tutorialLink": "मुफ्त कुंजी कैसे प्राप्त करें? ट्यूटोरियल देखें" + }, + "actions": { + "save": "सहेजें", + "clearAll": "सभी साफ़ करें" + } + }, + "auth": { + "steps": { + "pick": "जारी रखने के लिए लॉग इन करें", + "email": "ईमेल लॉग इन", + "otp": "सत्यापन कोड" + }, + "googleLogin": "Google लॉग इन", + "githubLogin": "GitHub लॉग इन", + "emailLogin": "ईमेल सत्यापन कोड लॉग इन", + "or": "या", + "emailPlaceholder": "your@email.com", + "sendCode": "कोड भेजें", + "sending": "भेजा जा रहा है...", + "codeSent": "सत्यापन कोड {email} पर भेजा गया", + "codePlaceholder": "6 अंकीय सत्यापन कोड", + "verify": "पुष्टि करें", + "verifying": "सत्यापन हो रहा है...", + "resend": "पुनः भेजें", + "back": "वापस", + "close": "बंद करें", + "ariaLabel": "लॉग इन" + }, + "history": { + "title": "कथा · इतिहास", + "close": "बंद करें", + "closeAriaLabel": "कथा इतिहास बंद करें", + "noHistory": "अभी कोई इतिहास नहीं है।", + "scene": "दृश्य {n}", + "choice": "चयन", + "action": "कार्य", + "ariaLabel": "कथा इतिहास" + }, + "customForm": { + "world": "दुनिया · दृष्टिकोण", + "style": "शैली · चित्र शैली", + "worldPlaceholder": "उदाहरण: 1990 के दशक के अंत में दक्षिणी चीन का एक छोटा शहर। मुख्य पात्र एक तीसरी वर्ष का स्थानांतरित छात्र है, जो बारिश वाले जून में छत पर कविता पढ़ने वाले एक सहपाठी से मिलता है।", + "stylePlaceholder": "उदाहरण: जल रंग कोमल प्रकाश, दोपहर की गर्मी, एनीमे दृश्य उपन्यास शैली...", + "status": { + "ready": "तैयार · हो · गया", + "needMore": "दो · अनुच्छेद · पर्याप्त", + "starting": "पहला दृश्य लोड हो रहा है..." + }, + "start": "शुरू करें" + }, + "language": { + "title": "भाषा", + "current": "वर्तमान भाषा", + "select": "भाषा चुनें" + } +} as const; + +export type hiTranslations = typeof hi; diff --git a/lib/i18n/locales/id.ts b/lib/i18n/locales/id.ts new file mode 100644 index 0000000..653a599 --- /dev/null +++ b/lib/i18n/locales/id.ts @@ -0,0 +1,89 @@ +// Indonesian +// Auto-generated by scripts/translate-i18n.mjs + +export const id = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? ' (login diperlukan selama beta, main gratis)' : ''; + return `Masukkan ide Anda, konfigurasi gaya, dan klik "Mulai" untuk bermain${authHint}. Anda juga dapat memilih cerita kurasi dari bawah untuk pengalaman cepat InfiPlot. Klik "Pengaturan" untuk memasukkan nama Anda dan mengonfigurasi kunci teks, gambar, visi, dan TTS Anda sendiri—semua disimpan secara lokal di browser Anda untuk pengalaman yang lebih stabil.`; + }, + "closeAriaLabel": "Jangan tampilkan petunjuk ini lagi" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type IdTranslations = typeof id; diff --git a/lib/i18n/locales/it.ts b/lib/i18n/locales/it.ts new file mode 100644 index 0000000..ab3d107 --- /dev/null +++ b/lib/i18n/locales/it.ts @@ -0,0 +1,89 @@ +// Italian +// Auto-generated by scripts/translate-i18n.mjs + +export const it = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? ' (accesso richiesto durante la beta, gioco gratuito)' : ''; + return `Inserisci le tue idee, configura gli stili e fai clic su "Inizia" per giocare${authHint}. Puoi anche scegliere una storia curata qui sotto per provare rapidamente InfiPlot. Fai clic su "Impostazioni" per inserire il tuo nome e configurare le tue chiavi di testo, immagine, visione e TTS—tutto salvato localmente nel tuo browser per un'esperienza più stabile.`; + }, + "closeAriaLabel": "Non mostrare più questo suggerimento" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type ItTranslations = typeof it; diff --git a/lib/i18n/locales/ja.ts b/lib/i18n/locales/ja.ts new file mode 100644 index 0000000..eb3de39 --- /dev/null +++ b/lib/i18n/locales/ja.ts @@ -0,0 +1,426 @@ +// Japanese — auto-translated from zh-CN by scripts/translate-i18n.mjs (review for quality). +// Extracted from components: page.tsx, layout.tsx, CustomForm.tsx, SettingsModal.tsx, PlayCanvas.tsx, AuthModal.tsx, DialogueHistoryModal.tsx + +export const ja = { + // ========== Layout ========== + layout: { + metadata: { + title: "InfiPlot — AIリアルタイムインタラクティブストーリーゲーム", + description: "InfiPlotは、AIを用いて画像、音声、ストーリー分岐をリアルタイムに生成するインタラクティブ・ストーリーゲームのデモです。", + }, + }, + + // ========== Home Page (page.tsx) ========== + home: { + // Example phrases for typewriter + examples: { + male: [ + "幼い頃から一緒に育った幼馴染が、突然顔を赤くして私に告白してきた", + "目が覚めたら、クラスの女子たちがみんな密かに俺のことを好きになっているみたいだ", + "三年の期は満ちた。実は私が御曹司だったとは。復讐の時が来た。", + "無限のTokenを手に、インターネット誕生の前夜へとタイムスリップした……", + ], + female: [ + "将軍家の落ちこぼれ嫡女に転生したのに、冷徹な摂政王は私だけを溺愛する", + "別れの前夜に巻き戻り、今度は私から手を放す", + "目が覚めたら乙女ゲームの悪役令嬢になっていた。すべての死亡エンドを回避しなくては", + ], + x: [ + "時空の裂け目が開き、複数の平行世界の自分が突如目の前に現れた", + "記憶の宮殿で、忘れ去られた断片が新たな物語へと再構成されている。", + "無限流ゲームが始まる。全員に与えられたクリアの機会は、ただ一度きり。", + "システム提示:あなたの選択が全宇宙の運命を左右します。", + ], + }, + + // Option labels + options: { + gender: "性的指向", + artStyle: "画風", + plotStyle: "シナリオスタイル", + voice: "ボイス", + pacing: "コンテンツのペース", + }, + + // Option values - genders + genders: { + male: "男性向け", + female: "女性向け", + x: "X", + }, + + // Option values - art styles + artStyles: { + auto: "オート", + custom: "カスタムスタイル", + kyoani: "京アニ", + shinkai: "新海誠", + ghibli: "ジブリ", + "3d": "3Dアニメーション", + cyberpunk: "サイバーパンク", + gothic: "ゴシック", + wasteland: "ポストアポカリプス", + pixel: "ドット絵風", + realistic: "現実", + oil: "古典油絵", + monet: "モネ", + watercolor: "水彩", + ink: "水墨", + ukiyoe: "浮世絵", + pencil: "色鉛筆", + sketch: "手描きスケッチ", + manga: "モノクロ漫画", + children: "子ども向け絵本", + crayon: "子どもの落書き", + clay: "粘土細工", + dunhuang: "敦煌壁画", + miniature: "細密画", + mosaic: "モザイク画", + stainedGlass: "ステンドグラス", + vaporwave: "ヴェイパーウェイヴ", + vector: "ベクターイラスト", + lowpoly: "ローポリゴン", + popart: "ポップアート", + glitch: "グリッチアート", + papercut: "切り絵", + steampunk: "スチームパンク", + xianxia: "仙侠ファンタジー", + darkFairytale: "暗黒童話", + urbanFantasy: "都市幻想", + }, + + // Option values - plot styles + plotStyles: { + straightforward: "ストレートな展開", + twist: "複数ルート分岐", + suspense: "サスペンス", + healing: "癒やし系日常", + }, + + // Option values - voice + voiceOptions: { + off: "オフ", + on: "オン", + }, + + // Option values - pacing + pacings: { + slow: "じっくり繊細", + fast: "テンポよく", + }, + + // Story cards (samples - in production these would come from presets.ts) + stories: { + // A few representative titles + 贤者陨落: "賢者の終焉", + 画中圣手: "画中の名手", + 花魁的刀: "花魁の刀", + // ... (full list would be presets.ts stories) + }, + + // UI labels + ui: { + start: "スタート", + loadStory: "シナリオ読み込み", + settings: "設定", + searchPlaceholder: "スタイルを検索…", + noMatchingStyle: "一致するスタイルがありません", + close: "閉じる", + back: "戻る", + save: "保存", + cancel: "キャンセル", + saveAndSelect: "保存して適用", + }, + + // Style modal + styleModal: { + title: "画風を選択", + subtitle: 'デフォルトは「自動」で、AIがストーリーに基づいて画風を自動的にマッチングします。「カスタムスタイル」を選択すると、説明の入力や参考画像のアップロードが可能です。', + customTitle: "カスタムスタイル", + customPlaceholder: `希望する画像スタイルを入力してください。例えば: +幻想的な水彩画風、柔らかな色調、ノスタルジックな雰囲気 + +💡 ヒント:一部の画像生成モデルは英語のプロンプトの方が効果が高いため、事前にAIチャットツール等で専門的な英語のスタイル記述を生成し、ここに貼り付けることをお勧めします。`, + uploadImage: "参考画像をアップロード", + changeImage: "別の画像にする", + remove: "削除", + parsing: "解析中…", + importFromPreset: "プリセットスタイルからインポート…", + uploadError: "画像ファイルのみ対応しています", + visionError: "視覚モデルが空のスタイル説明を返しました", + fileReadError: "ファイルの読み込みに失敗しました", + imageDecodeError: "画像をデコードできません", + parseError: "解析に失敗しました", + refImageAlt: "画風参考画像", + }, + + // Hero section + hero: { + title: "今日はどんな物語を体験したいですか?", + placeholder: " ", + enterHint: "Enterで送信 Shift+Enterで改行", + }, + + // Usage hint + hint: { + text: (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? '(テスト期間中、ログインするだけで無料でプレイできます)' : ''; + return `アイデアを入力し、スタイルを設定して、「開始」をクリックするだけでプレイできます${authHint}。また、下の厳選ストーリー集から1つ選んで、すぐに InfiPlot を体験することもできます。「設定」をクリックすると、あなたの名前や、ご自身のテキスト、画像生成、画像認識モデル、そしてボイス Key を入力することもできます。これらはすべてローカルブラウザにのみ保存されるため、より安定して体験できます。`; + }, + closeAriaLabel: "今後このメッセージを表示しない", + }, + + // About section + about: { + title: "InfiPlot", + description: "AIでコンテンツをリアルタイムに生成するインタラクティブ・ストーリーゲームです——画像、音声、ストーリーの分岐がプレイ中にその場で生成されます。", + team: "チーム", + teamText: "私たちは清華大学や蘭州大学などの大学の出身で、マルチモーダルモデルにおける「画像や動画の直接生成」といったoneshot機能の枠を超えた、さらなる可能性を模索しています。本プロジェクトは現在まだ初期段階にあり、メンバーを募集中です。もしご興味がございましたら、ぜひご連絡ください。皆様のご参加を心よりお待ちしております。", + contact: "連絡先", + email: "メールアドレス", + openSource: "ソースコード", + betaUsers: "クローズドβユーザーグループ", + qqGroupLabel: "QQグループ番号:", + qqGroupAlt: "InfiPlot オープンβ交流QQグループ QRコード(グループ番号 575404333)", + legalNotice: (params: { analyticsOn?: boolean }) => { + const base = `公開テスト期間中、本製品は無料でご利用いただけますが、同時接続ユーザー数によって動作の安定性が変動する場合があります。
公開テスト期間中に生成されたコンテンツはサーバーに保存されません。保存が必要な場合は、プレイ終了後に図集のエクスポートまたはストーリー共有機能を使用して、プレイ体験を保存してください。
AIによって生成されたコンテンツは、当チームの立場を代表するものではありません。`; + if (params.analyticsOn) { + return `${base}
当サイトは、オープンソースの Umami を使用して、プライバシーに配慮した匿名のアクセスおよびインタラクション統計を行っています:Cookieは使用せず、個人情報は収集せず、入力された内容は一切送信せず、クロスサイトトラッキングも行いません。`; + } + return base; + }, + privacyPolicy: "プライバシーポリシー", + terms: "利用規約", + copyright: "© 2026 InfiPlot. All rights reserved.", + }, + + // Story import errors + errors: { + emptyFile: "このシナリオファイルは空です。", + fileTooLarge: "シナリオファイルが大きすぎるため、ロードできません。", + unpackFailed: "シナリオファイルのアンパックに失敗しました。", + parseFailed: "シナリオファイルの解析に失敗しました。", + cardNotFound: "おすすめストーリーが見つかりません:{cardName}", + }, + }, + + // ========== Play Page (PlayCanvas.tsx & play/page.tsx) ========== + play: { + // Loading states + loading: { + firstFrame: "第一幕を描画中", + transitioning: "AIが次の幕を描画中", + visionThinking: "AIはあなたが何を見たか考えています", + loadingFirst: "第一幕を起動中", + awakening: "ロード中", + }, + + // Freeform input + freeform: { + placeholder: "言いたいことややりたいことを入力...", + title: "自由入力", + ariaLabel: "自由入力", + }, + + // Choice disabled title + choiceDisabled: "共有されたストーリーにこの分岐は含まれていません", + + // Tooltips + tooltips: { + openSettings: "設定を開く", + openHistory: "シナリオ巻き戻し", + fullscreen: "フルスクリーン (F)", + enterFullscreen: "全画面表示", + exportGallery: "このプレイをインタラクティブギャラリーのリンクとしてエクスポート(ボイス付き。直近2回分のリンクのみが保持されます)", + exportGalleryLabel: "インタラクティブな図表をエクスポート", + shareStory: "このプレイを続きからプレイ可能なシナリオ .infiplot(ボイス付き)としてエクスポート", + shareStoryLabel: "現在のストーリーをシェア", + mute: "ミュート", + unmute: "ミュート解除", + closeNudge: "ヒントを閉じる", + silenceNudge: "効果に満足できない/よく音が出ない?ご自身の API Key を入力してみてください", + back: "戻る", + }, + + // Image alt + imageAlt: "Generated scene", + + // Scene/beat counter + counter: { + scene: "第 {n} 幕", + beat: "{n} 拍", + middle: " ", + }, + + // Button labels + buttons: { + fullscreen: "Fキーで全画面", + exportGallery: "図集のエクスポート", + shareStory: "ストーリーを共有", + muted: "消音", + sound: "ボイスあり", + }, + + // Error state + error: { + title: "問題が発生しました", + back: "戻る", + }, + + // Previous action + previousStep: "前のアクション", + + // Settings footer note + settingsFooter: "保存後、ボイス Key はすぐに有効になり、ご自身のクレジットを使用して現在のシーンのボイスを合成します。", + + // Share file errors + shareErrors: { + notFound: "読み込むシナリオファイルが見つかりませんでした。", + invalid: "シナリオ共有ファイルにロード可能なシナリオがありません。", + noImage: "シナリオ共有ファイルに第一幕の画像がありません。", + noNextImage: "シナリオ共有ファイルに次のシーンの画像が不足しています。", + noMemory: "シナリオ共有ファイルに初期シナリオ記憶が不足しているため、ロードできません。", + packFailed: "シナリオ共有のパッケージ化に失敗しました", + }, + + // Export progress + exportProgress: { + preparingVoice: "ボイスを準備中", + }, + }, + + // ========== Settings Modal (SettingsModal.tsx) ========== + settings: { + title: "設定", + subtitle: "任意:これらの設定はローカルブラウザにのみ保存されます", + + // Tabs + tabs: { + general: "一般", + models: "モデル", + }, + + // General tab + general: { + playerName: "プレイヤー名", + playerNamePlaceholder: "未入力の場合は「あなた」を使用します", + playerNameHint: "NPCは会話の中でこの名前であなたを呼びます。入力しない場合はデフォルトで「あなた」と呼びます。", + visionClick: "画面をクリックして認識", + visionOn: "有効にする", + visionOff: "閉じる", + visionHint: "有効にすると、選択ノードで画面をクリックした際にAI画像認識がトリガーされ、新しいシナリオ分岐が生成されます。", + }, + + // Models tab + models: { + corsNotice: "お使いのAPIエンドポイントがブラウザのクロスオリジン要求(CORS)をサポートしていることを確認してください。ほとんどの主要プロバイダー(OpenAI、Anthropic、Gemini、Runwareなど)は、すでにデフォルトでサポートしています。", + textModel: "テキストモデル", + imageModel: "描画モデル", + visionModel: "画像認識モデル", + baseUrl: "BASE URL", + apiKey: "API Key", + model: "Model", + provider: "プロバイダー(任意)", + providerHint: "空欄の場合、システムは Base URL に基づいてプロトコルを自動的に推測します。", + providerAuto: "自動判定(推奨)", + show: "表示", + hide: "非表示", + }, + + // TTS section + tts: { + title: "ボイスモデル", + description: 'ご自身の Xiaomi MiMo API Key を入力すると、ボイスはブラウザのローカルで合成されます。Keyはローカルにのみ保存され、サーバーを経由することはありません。MiMo TTSは現在期間限定で無料となっており、申請すればすぐに使用できます。', + keyType: "Key タイプ", + payg: "従量課金", + paygSub: "sk-で始まる", + tokenPlan: "トークンプラン", + tokenPlanSub: "tp- で始まる", + region: "エリアノード", + regionHint: "ご契約プランの地域と一致するノードを選択してください(通常、最も遅延が少ないノードです)。", + apiKeyPlaceholderPayg: "sk-で始まる従量課金 Key を貼り付け", + apiKeyPlaceholderToken: "tp-で始まるプランKeyを貼り付け", + keyMismatchPayg: 'このKeyはsk-で始まっていません。選択した「従量課金 Pay-as-you-go」タイプと一致しない可能性があります。入力内容に誤りがないかご確認ください。', + keyMismatchToken: 'この Key は tp- で始まっていないため、選択された「プラン Token Plan」のタイプと一致しない可能性があります。入力内容に誤りがないかご確認ください。', + tutorialLink: "無料でKeyを申請するには?図解チュートリアルを見る", + }, + + // Actions + actions: { + save: "保存", + clearAll: "すべてクリア", + }, + }, + + // ========== Auth Modal (AuthModal.tsx) ========== + auth: { + // Steps + steps: { + pick: "ログインして続行", + email: "メールアドレスでログイン", + otp: "認証コード", + }, + + // Buttons + googleLogin: "Google ログイン", + githubLogin: "GitHubでログイン", + emailLogin: "メール認証コードでログイン", + or: "または", + + // Email input + emailPlaceholder: "your@email.com", + sendCode: "認証コードを送信", + sending: "送信中...", + + // OTP verification + codeSent: "認証コードを{email}に送信しました", + codePlaceholder: "6桁の認証コード", + verify: "確認", + verifying: "検証中...", + resend: "再送信", + + // Navigation + back: "戻る", + + // Close + close: "閉じる", + + // Aria labels + ariaLabel: "ログイン", + }, + + // ========== Dialogue History Modal ========== + history: { + title: "シナリオ回想", + close: "閉じる", + closeAriaLabel: "シナリオ回想を閉じる", + noHistory: "履歴はありません。", + scene: "第 {n} 幕", + choice: "選択", + action: "行動", + ariaLabel: "シナリオ巻き戻し", + }, + + // ========== Custom Form (CustomForm.tsx) ========== + customForm: { + world: "世界観", + style: "画風", + worldPlaceholder: "例:1990年代末の中国南部の地方都市。主人公は高校3年生の転校生。雨の多い6月に、いつも屋上で詩を読んでいる同級生と出会う。ストーリーはスロースタートで、控えめ、どこか切ない…", + stylePlaceholder: "例:水彩風の柔らかな光、午後の温もり、アニメ風ビジュアルノベル画風、従来の会話パネル…", + status: { + ready: "準備完了", + needMore: "2つの段落でスタート", + starting: "最初のフレームを呼び出し中…", + }, + start: "スタート", + }, + + // ========== Language Switcher ========== + language: { + title: "言語", + current: "現在の言語", + select: "言語の選択", + }, +} as const; + +export type JaTranslations = typeof ja; diff --git a/lib/i18n/locales/ko.ts b/lib/i18n/locales/ko.ts new file mode 100644 index 0000000..9c4cb96 --- /dev/null +++ b/lib/i18n/locales/ko.ts @@ -0,0 +1,89 @@ +// Korean +// Auto-generated by scripts/translate-i18n.mjs + +export const ko = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? '(베타 기간 중, 로그인하면 무료 플레이)' : ''; + return `아이디어를 입력하고 스타일을 구성한 후 "시작"을 클릭하여 플레이${authHint}. 또는 아래의 큐레이션된 스토리 중 하나를 선택하여 InfiPlot을 빠르게 경험할 수도 있습니다. "설정"을 클릭하여 이름과 텍스트, 이미지, 비전 모델, TTS 키를 입력할 수 있습니다—모두 브라우저에 로컬로 저장되어 더 안정적인 경험을 제공합니다.`; + }, + "closeAriaLabel": "이 힌트를 다시 표시하지 않음" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type KoTranslations = typeof ko; diff --git a/lib/i18n/locales/nl.ts b/lib/i18n/locales/nl.ts new file mode 100644 index 0000000..6bb72cc --- /dev/null +++ b/lib/i18n/locales/nl.ts @@ -0,0 +1,89 @@ +// Dutch +// Auto-generated by scripts/translate-i18n.mjs + +export const nl = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? ' (inloggen vereist tijdens beta, gratis spelen)' : ''; + return `Voer je ideeën in, configureer stijlen en klik op "Starten" om te spelen${authHint}. Je kunt ook een gecureerd verhaal onderaan kiezen om InfiPlot snel te ervaren. Klik op "Instellingen" om je naam in te voeren en je eigen tekst-, afbeeldings-, visie- en TTS-sleutels te configureren—alles lokaal in je browser opgeslagen voor een stabielere ervaring.`; + }, + "closeAriaLabel": "Deze hint niet meer weergeven" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type NlTranslations = typeof nl; diff --git a/lib/i18n/locales/pl.ts b/lib/i18n/locales/pl.ts new file mode 100644 index 0000000..505787e --- /dev/null +++ b/lib/i18n/locales/pl.ts @@ -0,0 +1,89 @@ +// Polish +// Auto-generated by scripts/translate-i18n.mjs + +export const pl = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? ' (wymagane logowanie podczas beta, darmowa gra)' : ''; + return `Wprowadź swoje pomysły, skonfiguruj style i kliknij "Rozpocznij", aby zagrać${authHint}. Możesz także wybrać kuratorską historię z dołu, aby szybko doświadczyć InfiPlot. Kliknij "Ustawienia", aby wprowadzić swoje imię i skonfigurować własne klucze tekstu, obrazu, widoku i TTS—wszystko przechowywane lokalnie w twojej przeglądarce dla bardziej stabilnego doświadczenia.`; + }, + "closeAriaLabel": "Nie pokazuj więcej tej podpowiedzi" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type PlTranslations = typeof pl; diff --git a/lib/i18n/locales/pt-BR.ts b/lib/i18n/locales/pt-BR.ts new file mode 100644 index 0000000..19ad3d2 --- /dev/null +++ b/lib/i18n/locales/pt-BR.ts @@ -0,0 +1,89 @@ +// Portuguese (Brazil) +// Auto-generated by scripts/translate-i18n.mjs + +export const ptBR = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? ' (login necessário durante o beta, grátis para jogar)' : ''; + return `Digite suas ideias, configure estilos e clique em "Iniciar" para jogar${authHint}. Você também pode escolher uma história curada abaixo para experimentar rapidamente InfiPlot. Clique em "Configurações" para inserir seu nome e configurar suas próprias chaves de texto, imagem, visão e TTS—tudo armazenado localmente no seu navegador para uma experiência mais estável.`; + }, + "closeAriaLabel": "Não mostrar mais este aviso" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type PtBRTranslations = typeof ptBR; diff --git a/lib/i18n/locales/pt.ts b/lib/i18n/locales/pt.ts new file mode 100644 index 0000000..bd50523 --- /dev/null +++ b/lib/i18n/locales/pt.ts @@ -0,0 +1,89 @@ +// Portuguese (Portugal) +// Auto-generated by scripts/translate-i18n.mjs + +export const pt = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? ' (login necessário durante o beta, grátis para jogar)' : ''; + return `Digite as suas ideias, configure estilos e clique em "Iniciar" para jogar${authHint}. Também pode escolher uma história curada abaixo para experimentar rapidamente InfiPlot. Clique em "Configurações" para inserir o seu nome e configurar as suas próprias chaves de texto, imagem, visão e TTS—tudo guardado localmente no seu navegador para uma experiência mais estável.`; + }, + "closeAriaLabel": "Não mostrar esta dica novamente" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type PtTranslations = typeof pt; diff --git a/lib/i18n/locales/ru.ts b/lib/i18n/locales/ru.ts new file mode 100644 index 0000000..b25e3c1 --- /dev/null +++ b/lib/i18n/locales/ru.ts @@ -0,0 +1,89 @@ +// Russian +// Auto-generated by scripts/translate-i18n.mjs + +export const ru = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? ' (требуется вход во время бета-теста, бесплатная игра)' : ''; + return `Введите свои идеи, настройте стили и нажмите "Начать" для игры${authHint}. Вы также можете выбрать выбранную историю ниже, чтобы быстро испытать InfiPlot. Нажмите "Настройки", чтобы ввести свое имя и настроить свои собственные ключи текста, изображения, зрения и TTS—все сохраняется локально в вашем браузере для более стабильного опыта.`; + }, + "closeAriaLabel": "Больше не показывать эту подсказку" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type RuTranslations = typeof ru; diff --git a/lib/i18n/locales/th.ts b/lib/i18n/locales/th.ts new file mode 100644 index 0000000..c5b4cf5 --- /dev/null +++ b/lib/i18n/locales/th.ts @@ -0,0 +1,89 @@ +// Thai +// Auto-generated by scripts/translate-i18n.mjs + +export const th = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? ' (ต้องล็อกอินระหว่างเบต้า, เล่นฟรี)' : ''; + return `ป้อนแนวคิดของคุณ กำหนดค่าสไตล์ และคลิก "เริ่ม" เพื่อเล่น${authHint} คุณยังสามารถเลือกเรื่องราวที่คัดสรรจากด้านล่างเพื่อสัมผัส InfiPlot ได้อย่างรวดเร็ว คลิก "การตั้งค่า" เพื่อป้อนชื่อและกำหนดค่าคีย์ข้อความ รูปภาพ การมองเห็น และ TTS ของคุณเอง—ทั้งหมดจะถูกเก็บไว้ในเบราว์เซอร์ของคุณเพื่อประสบการณ์ที่มีเสถียรภาพมากขึ้น`; + }, + "closeAriaLabel": "ไม่แสดงคำแนะนำนี้อีก" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type ThTranslations = typeof th; diff --git a/lib/i18n/locales/tr.ts b/lib/i18n/locales/tr.ts new file mode 100644 index 0000000..8a91b18 --- /dev/null +++ b/lib/i18n/locales/tr.ts @@ -0,0 +1,89 @@ +// Turkish +// Auto-generated by scripts/translate-i18n.mjs + +export const tr = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? ' (beta sırasında giriş gerekli, ücretsiz oyun)' : ''; + return `Fikirlerinizi girin, stilleri yapılandırın ve oynamak için "Başlat"a tıklayın${authHint}. Aşağıdan küratörlü bir hikaye seçerek InfiPlot'ı hızlıca deneyimleyebilirsiniz. "Ayarlar"a tıklayarak adınızı girebilir ve kendi metin, resim, görü ve TTS anahtarlarınızı yapılandırabilirsiniz—tümü daha stabil bir deneyim için tarayıcınızda yerel olarak saklanır.`; + }, + "closeAriaLabel": "Bu ipucunu bir daha gösterme" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type TrTranslations = typeof tr; diff --git a/lib/i18n/locales/uk.ts b/lib/i18n/locales/uk.ts new file mode 100644 index 0000000..8fc4dc5 --- /dev/null +++ b/lib/i18n/locales/uk.ts @@ -0,0 +1,89 @@ +// Ukrainian +// Auto-generated by scripts/translate-i18n.mjs + +export const uk = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? ' (вхід потрібен під час бета-тестування, безкоштовна гра)' : ''; + return `Введіть свої ідеї, налаштуйте стилі та натисніть "Почати" для гри${authHint}. Ви також можете обрати вибрану історію знизу, щоб швидко випробувати InfiPlot. Натисніть "Налаштування", щоб ввести своє ім'я та налаштувати власні ключі тексту, зображення, зору та TTS—все зберігається локально у вашому браузері для стабільнішого досвіду.`; + }, + "closeAriaLabel": "Більше не показувати цю підказку" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type UkTranslations = typeof uk; diff --git a/lib/i18n/locales/vi.ts b/lib/i18n/locales/vi.ts new file mode 100644 index 0000000..7af896f --- /dev/null +++ b/lib/i18n/locales/vi.ts @@ -0,0 +1,89 @@ +// Vietnamese +// Auto-generated by scripts/translate-i18n.mjs + +export const vi = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? ' (yêu cầu đăng nhập trong bản beta, chơi miễn phí)' : ''; + return `Nhập ý tưởng của bạn, cấu hình kiểu và nhấp "Bắt đầu" để chơi${authHint}. Bạn cũng có thể chọn một câu chuyện được chọn từ bên dưới để trải nghiệm nhanh InfiPlot. Nhấp "Cài đặt" để nhập tên của bạn và cấu hình khóa văn bản, hình ảnh, hình ảnh và TTS của riêng bạn—tất cả được lưu cục bộ trong trình duyệt của bạn để có trải nghiệm ổn định hơn.`; + }, + "closeAriaLabel": "Không còn hiển thị gợi ý này" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type ViTranslations = typeof vi; diff --git a/lib/i18n/locales/zh-CN.ts b/lib/i18n/locales/zh-CN.ts new file mode 100644 index 0000000..28d36ff --- /dev/null +++ b/lib/i18n/locales/zh-CN.ts @@ -0,0 +1,426 @@ +// Chinese (Simplified) - Source language +// Extracted from components: page.tsx, layout.tsx, CustomForm.tsx, SettingsModal.tsx, PlayCanvas.tsx, AuthModal.tsx, DialogueHistoryModal.tsx + +export const zhCN = { + // ========== Layout ========== + layout: { + metadata: { + title: "InfiPlot — AI 实时交互剧情游戏", + description: "InfiPlot 是一款用 AI 实时生成图片、语音与剧情分支的交互式剧情游戏 Demo。", + }, + }, + + // ========== Home Page (page.tsx) ========== + home: { + // Example phrases for typewriter + examples: { + male: [ + "从小一起长大的青梅竹马,突然红着脸向我告白", + "一觉醒来,班上的女生好像都偷偷喜欢上了我", + "三年之期已到,原来我是富家公子,报仇时机已到", + "我带着无限 Token 穿越回了互联网诞生前夕……", + ], + female: [ + "穿越成将军府的废物嫡女,冷面摄政王却独宠我一人", + "重生回到分手前夜,这一次换我先放手", + "一觉醒来成了乙游里的恶役千金,要躲开所有死亡结局", + ], + x: [ + "时空裂隙开启,多个平行世界的自己突然出现在眼前", + "记忆宫殿里,那些被遗忘的碎片正在重组为新的故事", + "一场无限流游戏开始,所有人都有唯一的通关机会", + "系统提示:你的选择将决定整个宇宙的命运走向", + ], + }, + + // Option labels + options: { + gender: "性向", + artStyle: "绘画风格", + plotStyle: "剧情风格", + voice: "语音配音", + pacing: "内容节奏", + }, + + // Option values - genders + genders: { + male: "男性向", + female: "女性向", + x: "X", + }, + + // Option values - art styles + artStyles: { + auto: "自动", + custom: "自定义风格", + kyoani: "京阿尼", + shinkai: "新海诚", + ghibli: "吉卜力", + "3d": "3D 动画", + cyberpunk: "赛博朋克", + gothic: "哥特", + wasteland: "废土", + pixel: "像素风", + realistic: "真实", + oil: "古典油画", + monet: "莫奈", + watercolor: "水彩", + ink: "水墨", + ukiyoe: "浮世绘", + pencil: "彩铅", + sketch: "手绘素描", + manga: "黑白漫画", + children: "儿童绘本", + crayon: "儿童涂鸦", + clay: "黏土手工", + dunhuang: "敦煌壁画", + miniature: "细密画", + mosaic: "镶嵌画", + stainedGlass: "彩绘玻璃", + vaporwave: "蒸汽波", + vector: "矢量插画", + lowpoly: "低多边形", + popart: "波普艺术", + glitch: "故障艺术", + papercut: "剪纸艺术", + steampunk: "蒸汽朋克", + xianxia: "仙侠玄幻", + darkFairytale: "暗黑童话", + urbanFantasy: "都市幻想", + }, + + // Option values - plot styles + plotStyles: { + straightforward: "平铺直叙", + twist: "多线转折", + suspense: "悬疑烧脑", + healing: "治愈日常", + }, + + // Option values - voice + voiceOptions: { + off: "关闭", + on: "开启", + }, + + // Option values - pacing + pacings: { + slow: "慢热细腻", + fast: "紧凑爽快", + }, + + // Story cards (samples - in production these would come from presets.ts) + stories: { + // A few representative titles + 贤者陨落: "贤者陨落", + 画中圣手: "画中圣手", + 花魁的刀: "花魁的刀", + // ... (full list would be presets.ts stories) + }, + + // UI labels + ui: { + start: "开始", + loadStory: "载入剧情", + settings: "设置", + searchPlaceholder: "搜索风格…", + noMatchingStyle: "没有匹配的风格", + close: "关闭", + back: "返回", + save: "保存", + cancel: "取消", + saveAndSelect: "保存并选用", + }, + + // Style modal + styleModal: { + title: "选择绘画风格", + subtitle: '默认「自动」· 由 AI 根据故事自动匹配画风;选择「自定义风格」可输入描述或上传参考图', + customTitle: "自定义风格", + customPlaceholder: `描述你想要的画面风格,例如: +梦幻水彩风格,柔和的色调,怀旧的氛围 + +💡 提示:部分绘图模型对英文提示词效果更佳,建议先借助 AI 对话工具生成专业的英文风格描述,再粘贴到这里`, + uploadImage: "上传参考图", + changeImage: "换一张", + remove: "移除", + parsing: "解析中…", + importFromPreset: "从预设风格导入…", + uploadError: "只支持图片文件", + visionError: "视觉模型返回了空的风格描述", + fileReadError: "读取文件失败", + imageDecodeError: "无法解码图片", + parseError: "解析失败", + refImageAlt: "画风参考图", + }, + + // Hero section + hero: { + title: "今天想体验什么故事?", + placeholder: " ", + enterHint: "Enter 发送 · Shift+Enter 换行", + }, + + // Usage hint + hint: { + text: (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? '(测试期间,登录即可免费畅玩)' : ''; + return `输入想法、配置风格,点击「开始」即可游玩${authHint};也可以从下方精选故事集挑一篇快速体验 InfiPlot。点击「设置」还能填入你的名字,以及你自己的文本、绘图、识图模型和配音 Key——全部只存在本地浏览器,体验更稳定。`; + }, + closeAriaLabel: "不再显示此提示", + }, + + // About section + about: { + title: "InfiPlot", + description: "是一款用 AI 实时生成内容的交互式剧情游戏 —— 图片、语音与剧情分支都在游玩过程中即时生成。", + team: "团 队", + teamText: "我们来自清华大学、兰州大学等高校,希望探索多模态模型在「直接生成图片、视频」这类 oneshot 能力之外,更多的可能性。本项目目前仍处于早期阶段,我们还在招募成员,如果你也感兴趣,欢迎联系我们,期待你的加入。", + contact: "联 系 方 式", + email: "邮箱", + openSource: "开 源 地 址", + betaUsers: "内 测 用 户 群", + qqGroupLabel: "QQ群号:", + qqGroupAlt: "InfiPlot 公测交流群 QQ 群二维码(群号 575404333)", + legalNotice: (params: { analyticsOn?: boolean }) => { + const base = `公测期间本产品可免费使用,但稳定性可能会随并发用户数量而有波动。
公测期间生成的内容不会在服务器上保存。如需留存,请在游玩结束后使用导出图集或分享剧情功能保存您的游玩体验。
AI 生成的内容不代表本团队立场。`; + if (params.analyticsOn) { + return `${base}
本站使用开源的 Umami 进行隐私友好的匿名访问与交互统计:不使用 Cookie、不收集个人信息、不发送任何您输入的内容、不做跨站追踪。`; + } + return base; + }, + privacyPolicy: "隐私政策", + terms: "服务条款", + copyright: "© 2026 InfiPlot. All rights reserved.", + }, + + // Story import errors + errors: { + emptyFile: "这个剧情文件是空的。", + fileTooLarge: "剧情文件太大,无法载入。", + unpackFailed: "剧情文件解包失败。", + parseFailed: "剧情文件解析失败。", + cardNotFound: "找不到精选剧情:{cardName}", + }, + }, + + // ========== Play Page (PlayCanvas.tsx & play/page.tsx) ========== + play: { + // Loading states + loading: { + firstFrame: "正 · 在 · 绘 · 制 · 第 · 一 · 幕", + transitioning: "AI · 正 · 在 · 描 · 画 · 下 · 一 · 幕", + visionThinking: "AI · 正 · 在 · 想 · 你 · 看 · 到 · 了 · 什 · 么", + loadingFirst: "正 · 在 · 唤 · 起 · 第 · 一 · 幕", + awakening: "载入中", + }, + + // Freeform input + freeform: { + placeholder: "输入你想说的或想做的...", + title: "自由输入", + ariaLabel: "自由输入", + }, + + // Choice disabled title + choiceDisabled: "分享剧情未包含这条分支", + + // Tooltips + tooltips: { + openSettings: "打开设置", + openHistory: "剧情回溯", + fullscreen: "全屏 (F)", + enterFullscreen: "进入全屏", + exportGallery: "导出本局为可交互图集链接(含配音;只会保留最近两次的可交互图集链接)", + exportGalleryLabel: "导出可交互图集", + shareStory: "导出本局为可继续游玩的剧情 .infiplot(含配音)", + shareStoryLabel: "分享当前剧情", + mute: "静音", + unmute: "取消静音", + closeNudge: "关闭提示", + silenceNudge: "效果不满意/经常没声音?填入自己的 API Key 试试", + back: "返回", + }, + + // Image alt + imageAlt: "Generated scene", + + // Scene/beat counter + counter: { + scene: "第 · {n} · 幕", + beat: "{n} · 拍", + middle: "·", + }, + + // Button labels + buttons: { + fullscreen: "F · 键 · 全 · 屏", + exportGallery: "导 · 出 · 图 · 集", + shareStory: "分 · 享 · 剧 · 情", + muted: "静 · 音", + sound: "有 · 声", + }, + + // Error state + error: { + title: "出 · 了 · 点 · 状 · 况", + back: "返 · 回", + }, + + // Previous action + previousStep: "上 · 一 · 步 ·", + + // Settings footer note + settingsFooter: "保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。", + + // Share file errors + shareErrors: { + notFound: "没有找到要载入的剧情文件。", + invalid: "剧情分享文件没有可载入的剧情。", + noImage: "剧情分享文件缺少第一幕图片。", + noNextImage: "剧情分享文件缺少下一幕图片。", + noMemory: "剧情分享文件缺少初始剧情记忆,无法载入。", + packFailed: "剧情分享打包失败", + }, + + // Export progress + exportProgress: { + preparingVoice: "正在准备配音", + }, + }, + + // ========== Settings Modal (SettingsModal.tsx) ========== + settings: { + title: "设置", + subtitle: "可选 · 这些设置仅保存在本地浏览器", + + // Tabs + tabs: { + general: "通用", + models: "模型", + }, + + // General tab + general: { + playerName: "玩家名字", + playerNamePlaceholder: "不填则使用「你」", + playerNameHint: "NPC 会在对话中用这个名字称呼你。不填则默认以「你」称呼。", + visionClick: "点击画面识别", + visionOn: "开启", + visionOff: "关闭", + visionHint: "开启后,在选择节点点击画面会触发 AI 识图并生成新的剧情分支。", + }, + + // Models tab + models: { + corsNotice: "请确保你的 API 端点支持浏览器跨域请求(CORS)。大多数主流提供商(OpenAI、Anthropic、Gemini、Runware 等)已默认支持。", + textModel: "文本模型", + imageModel: "绘图模型", + visionModel: "识图模型", + baseUrl: "BASE URL", + apiKey: "API Key", + model: "Model", + provider: "Provider(可选)", + providerHint: "留空时系统会根据 Base URL 自动推断协议。", + providerAuto: "自动推断(推荐)", + show: "显示", + hide: "隐藏", + }, + + // TTS section + tts: { + title: "配音模型", + description: '填入你自己的 小米 MiMo API Key,配音将在浏览器本地合成,Key 只保存在本地、绝不经过服务器。MiMo TTS 目前限时免费,申请即可使用。', + keyType: "Key 类型", + payg: "按量付费 Pay-as-you-go", + paygSub: "sk- 开头", + tokenPlan: "套餐 Token Plan", + tokenPlanSub: "tp- 开头", + region: "区域节点", + regionHint: "选择与你的套餐订阅地区一致的节点(通常也是延迟最低的那个)。", + apiKeyPlaceholderPayg: "粘贴 sk- 开头的按量 Key", + apiKeyPlaceholderToken: "粘贴 tp- 开头的套餐 Key", + keyMismatchPayg: '此 Key 不是 sk- 开头,可能与所选「按量付费 Pay-as-you-go」类型不符,请确认是否填错。', + keyMismatchToken: '此 Key 不是 tp- 开头,可能与所选「套餐 Token Plan」类型不符,请确认是否填错。', + tutorialLink: "如何免费申请 Key?查看图文教程", + }, + + // Actions + actions: { + save: "保存", + clearAll: "全部清除", + }, + }, + + // ========== Auth Modal (AuthModal.tsx) ========== + auth: { + // Steps + steps: { + pick: "登录以继续", + email: "邮箱登录", + otp: "验证码", + }, + + // Buttons + googleLogin: "Google 登录", + githubLogin: "GitHub 登录", + emailLogin: "邮箱验证码登录", + or: "或", + + // Email input + emailPlaceholder: "your@email.com", + sendCode: "发送验证码", + sending: "发送中...", + + // OTP verification + codeSent: "验证码已发送至 {email}", + codePlaceholder: "6 位验证码", + verify: "确认", + verifying: "验证中...", + resend: "重新发送", + + // Navigation + back: "返回", + + // Close + close: "关闭", + + // Aria labels + ariaLabel: "登录", + }, + + // ========== Dialogue History Modal ========== + history: { + title: "剧 · 情 · 回 · 溯", + close: "关闭", + closeAriaLabel: "关闭剧情回溯", + noHistory: "暂无历史。", + scene: "第 {n} 幕", + choice: "选择", + action: "行动", + ariaLabel: "剧情回溯", + }, + + // ========== Custom Form (CustomForm.tsx) ========== + customForm: { + world: "World · 世界观", + style: "Style · 画风", + worldPlaceholder: "例:1990 年代末的中国南方县城。主角是高三转学生,在多雨的六月遇到一个总在天台读诗的同学。剧情慢热、含蓄、带点伤感⋯", + stylePlaceholder: "例:水彩柔光,午后暖意,动漫视觉小说画风,传统对话面板⋯", + status: { + ready: "准 · 备 · 就 · 绪", + needMore: "两 · 段 · 即 · 可 · 开 · 场", + starting: "正在唤起第一帧…", + }, + start: "开 始", + }, + + // ========== Language Switcher ========== + language: { + title: "语言", + current: "当前语言", + select: "选择语言", + }, +} as const; + +export type ZhCNTranslations = typeof zhCN; diff --git a/lib/i18n/locales/zh-HK.ts b/lib/i18n/locales/zh-HK.ts new file mode 100644 index 0000000..eab2ab9 --- /dev/null +++ b/lib/i18n/locales/zh-HK.ts @@ -0,0 +1,89 @@ +// Traditional Chinese (Hong Kong) +// Auto-generated by scripts/translate-i18n.mjs + +export const zhHK = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? '(測試期間,登入即可免費暢玩)' : ''; + return `輸入想法、配置風格,點擊「開始」即可遊玩${authHint};也可以從下方精選故事集挑一篇快速體驗 InfiPlot。點擊「設置」還能填入你的名字,以及你自己的文本、繪圖、識圖模型和配音 Key——全部只存在本地瀏覽器,體驗更穩定。`; + }, + "closeAriaLabel": "不再顯示此提示" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type ZhHKTranslations = typeof zhHK; diff --git a/lib/i18n/locales/zh-TW.ts b/lib/i18n/locales/zh-TW.ts new file mode 100644 index 0000000..1280782 --- /dev/null +++ b/lib/i18n/locales/zh-TW.ts @@ -0,0 +1,89 @@ +// Traditional Chinese (Taiwan) +// Auto-generated by scripts/translate-i18n.mjs + +export const zhTW = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": (params: { authEnabled?: boolean }) => { + const authHint = params.authEnabled ? '(測試期間,登入即可免費暢玩)' : ''; + return `輸入想法、配置風格,點擊「開始」即可遊玩${authHint};也可以從下方精選故事集挑一篇快速體驗 InfiPlot。點擊「設置」還能填入你的名字,以及你自己的文本、繪圖、識圖模型和配音 Key——全部只存在本地瀏覽器,體驗更穩定。`; + }, + "closeAriaLabel": "不再顯示此提示" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type ZhTWTranslations = typeof zhTW; diff --git a/lib/i18n/server.ts b/lib/i18n/server.ts new file mode 100644 index 0000000..da361e5 --- /dev/null +++ b/lib/i18n/server.ts @@ -0,0 +1,176 @@ +import type { Locale } from "./config"; +import { DEFAULT_LOCALE, getInitialLocale } from "./config"; +import { getNestedValue, formatTranslation } from "./utils"; + +// Server-side translation cache +const translationCache = new Map>(); + +// Get locale from request headers +export function getLocaleFromHeaders(headers: Headers): Locale { + // Check for custom locale header + const customLocale = headers.get("x-locale"); + if (customLocale) { + return customLocale as Locale; + } + + // Check Accept-Language header + const acceptLanguage = headers.get("accept-language"); + if (acceptLanguage) { + const browserLang = acceptLanguage.split(",")[0]?.split("-")[0]; + // Map common language codes to our locales + const localeMap: Record = { + en: "en", + zh: "zh-CN", + ja: "ja", + ko: "ko", + es: "es", + fr: "fr", + de: "de", + pt: "pt", + ru: "ru", + it: "it", + vi: "vi", + th: "th", + id: "id", + tr: "tr", + pl: "pl", + nl: "nl", + uk: "uk", + hi: "hi", + cs: "cs", + }; + + const browserLangBase = acceptLanguage.split(",")[0]?.split("-")[0]; + if (browserLangBase) { + const matched = localeMap[browserLangBase]; + if (matched) return matched; + } + } + + return DEFAULT_LOCALE; +} + +// Load translations for server-side +export async function loadTranslations(locale: Locale): Promise> { + // Check cache first + if (translationCache.has(locale)) { + return translationCache.get(locale)!; + } + + try { + // Dynamic import based on locale + let translations; + switch (locale) { + case "zh-CN": + translations = (await import("./locales/zh-CN")).zhCN; + break; + case "en": + translations = (await import("./locales/en")).en; + break; + case "zh-TW": + translations = (await import("./locales/zh-TW")).zhTW; + break; + case "zh-HK": + translations = (await import("./locales/zh-HK")).zhHK; + break; + case "ja": + translations = (await import("./locales/ja")).ja; + break; + case "ko": + translations = (await import("./locales/ko")).ko; + break; + case "es": + translations = (await import("./locales/es")).es; + break; + case "fr": + translations = (await import("./locales/fr")).fr; + break; + case "de": + translations = (await import("./locales/de")).de; + break; + case "pt-BR": + translations = (await import("./locales/pt-BR")).ptBR; + break; + case "pt": + translations = (await import("./locales/pt")).pt; + break; + case "ru": + translations = (await import("./locales/ru")).ru; + break; + case "it": + translations = (await import("./locales/it")).it; + break; + case "vi": + translations = (await import("./locales/vi")).vi; + break; + case "th": + translations = (await import("./locales/th")).th; + break; + case "id": + translations = (await import("./locales/id")).id; + break; + case "tr": + translations = (await import("./locales/tr")).tr; + break; + case "pl": + translations = (await import("./locales/pl")).pl; + break; + case "nl": + translations = (await import("./locales/nl")).nl; + break; + case "uk": + translations = (await import("./locales/uk")).uk; + break; + case "hi": + translations = (await import("./locales/hi")).hi; + break; + case "cs": + translations = (await import("./locales/cs")).cs; + break; + default: + console.warn(`Translations for ${locale} not found, using English fallback`); + translations = (await import("./locales/en")).en; + break; + } + + translationCache.set(locale, translations as Record); + return translations as Record; + } catch (error) { + console.error(`Failed to load translations for ${locale}:`, error); + // Fallback to default locale + const fallback = await import("./locales/zh-CN"); + return fallback.zhCN as Record; + } +} + +// Server-side translation function +export async function getTranslations(locale: Locale): Promise> { + return loadTranslations(locale); +} + +// Create a translation function for server components +export function createTranslator(translations: Record) { + return function t(key: string, params: Record = {}): string { + const value = getNestedValue(translations, key); + + if (value === undefined) { + console.warn(`Translation missing for key: ${key}`); + return key; + } + + if (typeof value === "function") { + return (value as (params: Record) => string)(params); + } + + if (typeof value === "string") { + return formatTranslation(value, params); + } + + return String(value); + }; +} + +// Get initial locale for server components +export function getServerLocale(): Locale { + return DEFAULT_LOCALE; // Will be overridden by middleware in production +} diff --git a/lib/i18n/types.ts b/lib/i18n/types.ts new file mode 100644 index 0000000..ffd397c --- /dev/null +++ b/lib/i18n/types.ts @@ -0,0 +1,18 @@ +import type { Locale } from "./config"; + +// Translation value type - can be a string or a function that takes parameters +export type TranslationValue = string | ((params: Record) => string); + +// Translation structure - nested objects with translation values at leaves +export type TranslationStructure = Record; + +// Flatten a nested object to dot-notation keys +export type Flatten = T extends object + ? { + [K in keyof T]: T[K] extends (...args: any[]) => any + ? T[K] + : T[K] extends object + ? Flatten + : T[K]; + }[keyof T] + : T; diff --git a/lib/i18n/utils.ts b/lib/i18n/utils.ts new file mode 100644 index 0000000..47df518 --- /dev/null +++ b/lib/i18n/utils.ts @@ -0,0 +1,87 @@ +import type { Locale } from "./config"; + +/** + * Get a nested value from an object using a dot-notation path + * @example getNestedValue({ a: { b: "c" } }, "a.b") // "c" + */ +export function getNestedValue(obj: T, path: string): unknown { + return path.split(".").reduce((current, key) => { + if (current && typeof current === "object" && key in current) { + return (current as Record)[key]; + } + return undefined; + }, obj); +} + +/** + * Format a translation string with parameters + * Supports both {{key}} syntax and simple function-based interpolation + */ +export function formatTranslation( + template: string, + params: Record, +): string { + if (Object.keys(params).length === 0) return template; + + return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => { + return params[key]?.toString() ?? `{{${key}}}`; + }); +} + +/** + * Deep merge two objects + */ +export function deepMerge>( + target: T, + source: Partial, +): T { + const result = { ...target }; + + for (const key in source) { + if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key])) { + if (result[key] && typeof result[key] === "object" && !Array.isArray(result[key])) { + result[key] = deepMerge( + result[key] as Record, + source[key] as Record, + ) as T[Extract]; + } else { + result[key] = source[key] as T[Extract]; + } + } else { + result[key] = source[key] as T[Extract]; + } + } + + return result; +} + +/** + * Validate locale string + */ +export function isValidLocale(locale: string): locale is Locale { + const validLocales: Locale[] = [ + "en", + "zh-CN", + "zh-TW", + "zh-HK", + "ja", + "ko", + "es", + "fr", + "de", + "pt-BR", + "pt", + "ru", + "it", + "vi", + "th", + "id", + "tr", + "pl", + "nl", + "uk", + "hi", + "cs", + ]; + return validLocales.includes(locale as Locale); +} diff --git a/lib/types/index.ts b/lib/types/index.ts index 47afee2..2786e47 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -309,6 +309,14 @@ export type Session = { * only (localStorage); never persisted server-side. */ playerName?: string; + /** + * Active UI locale when the session was started, in BCP-47 form (e.g. + * "zh-CN", "en", "ja"). The engine appends a single-line language directive + * to the Architect / Writer user messages so AI-generated dialogue, beats, + * and narration are produced in this language. Absent → "zh-CN" for + * back-compat with sessions created before this field existed. + */ + language?: string; }; // ────────────────────────────────────────────────────────────────────── @@ -428,6 +436,9 @@ export type StartRequest = { orientation?: Orientation; /** Optional player display name — see Session.playerName. */ playerName?: string; + /** Active UI locale — see Session.language. Drives the engine's language + * directive so AI output is generated in the player's chosen language. */ + language?: string; }; // /api/parse-style-image — vision LLM extracts a textual painting-style diff --git a/scripts/add-all-missing-sections.mjs b/scripts/add-all-missing-sections.mjs new file mode 100644 index 0000000..97eaabc --- /dev/null +++ b/scripts/add-all-missing-sections.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +// Add all missing sections to locale files + +import { readFileSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const localesDir = resolve(__dirname, '../lib/i18n/locales'); + +// Read zh-CN to get the complete structure +const zhCNContent = readFileSync(resolve(localesDir, 'zh-CN.ts'), 'utf-8'); + +// Extract the play section from zh-CN (we need to add this) +const playSection = ` "play": { + "loading": { + "firstFrame": "正 · 在 · 绘 · 制 · 第 · 一 · 幕", + "transitioning": "AI · 正 · 在 · 描 · 画 · 下 · 一 · 幕", + "visionThinking": "AI · 正 · 在 · 想 · 你 · 看 · 到 · 了 · 什 · 么", + "loadingFirst": "正 · 在 · 唤 · 起 · 第 · 一 · 幕", + "awakening": "载入中", + }, + + "freeform": { + "placeholder": "输入你想说的或想做的...", + "title": "自由输入", + "ariaLabel": "自由输入", + }, + + "choiceDisabled": "分享剧情未包含这条分支", + + "tooltips": { + "openSettings": "打开设置", + "openHistory": "剧情回溯", + "fullscreen": "全屏 (F)", + "enterFullscreen": "进入全屏", + "exportGallery": "导出本局为可交互图集链接(含配音;只会保留最近两次的可交互图集链接)", + "exportGalleryLabel": "导出可交互图集", + "shareStory": "导出本局为可继续游玩的剧情 .infiplot(含配音)", + "shareStoryLabel": "分享当前剧情", + "mute": "静音", + "unmute": "取消静音", + "closeNudge": "关闭提示", + "silenceNudge": "效果不满意/经常没声音?填入自己的 API Key 试试", + "back": "返回", + }, + + "imageAlt": "Generated scene", + + "counter": { + "scene": "第 · {n} · 幕", + "beat": "{n} · 拍", + "middle": "·", + }, + + "buttons": { + "fullscreen": "F · 键 · 全 · 屏", + "exportGallery": "导 · 出 · 图 · 集", + "shareStory": "分 · 享 · 剧 · 情", + "muted": "静 · 音", + "sound": "有 · 声", + }, + + "error": { + "title": "出 · 了 · 点 · 状 · 况", + "back": "返 · 回", + }, + + "previousStep": "上 · 一 · 步 ·", + + "settingsFooter": "保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。", + + "shareErrors": { + "notFound": "没有找到要载入的剧情文件。", + "invalid": "剧情分享文件没有可载入的剧情。", + "noImage": "剧情分享文件缺少第一幕图片。", + "noNextImage": "剧情分享文件缺少下一幕图片。", + "noMemory": "剧情分享文件缺少初始剧情记忆,无法载入。", + "packFailed": "剧情分享打包失败", + }, + }`; + +// Extract other sections from zh-CN +const settingsMatch = zhCNContent.match(/ "settings": \{[^}]*"actions": \{[^}]*\}\s*\},/s); +const authMatch = zhCNContent.match(/ "auth": \{[^}]*"ariaLabel": "[^"]*"\s*\},/s); +const historyMatch = zhCNContent.match(/ "history": \{[^}]*"ariaLabel": "[^"]*"\s*\},/s); +const customFormMatch = zhCNContent.match(/ "customForm": \{[^}]*"start": "[^"]*"\s*\},/s); + +// Target locales +const targetLocales = [ + 'zh-TW', 'zh-HK', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'pt', 'ru', + 'it', 'vi', 'th', 'id', 'tr', 'pl', 'nl', 'uk', 'hi', 'cs' +]; + +function fixLocaleFile(content, locale) { + // Check if file already ends properly with "as const;" + if (content.trim().endsWith('as const;')) { + console.log(` ${locale}.ts already properly formatted`); + return null; + } + + // Find where the language section ends (around line 280) + const languageEndPattern = / "language": \{\s*"title": "[^"]*",\s*"current": "[^"]*",\s*"select": "[^"]*"\s*\}\s*/; + + // Build the replacement + const replacement = `$&\n${playSection},`; + + if (!languageEndPattern.test(content)) { + console.log(` No language section found in ${locale}.ts`); + return null; + } + + content = content.replace(languageEndPattern, replacement); + + // Add the closing "as const;" and type export + const exportType = `export type ${locale.replace('-', '')}Translations = typeof ${locale.replace('-', '')};`; + + // Remove any trailing content and add proper ending + const trailingContentPattern = /\s*\}[\s\S]*$/; + content = content.replace(trailingContentPattern, `\n} as const;\n\n${exportType}`); + + return content; +} + +let successCount = 0; +for (const locale of targetLocales) { + try { + const filePath = resolve(localesDir, `${locale}.ts`); + const content = readFileSync(filePath, 'utf-8'); + const newContent = fixLocaleFile(content, locale); + + if (newContent) { + writeFileSync(filePath, newContent); + console.log(`✓ Fixed ${locale}.ts`); + successCount++; + } + } catch (e) { + console.error(`✗ Error updating ${locale}:`, e.message); + } +} + +console.log(`\nDone! Fixed ${successCount} locale files`); diff --git a/scripts/add-cardnotfound-key.mjs b/scripts/add-cardnotfound-key.mjs new file mode 100644 index 0000000..bad93e5 --- /dev/null +++ b/scripts/add-cardnotfound-key.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// Add home.errors.cardNotFound key to all locales + +import { readFileSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const localesDir = resolve(__dirname, '../lib/i18n/locales'); + +const keyToAdd = ' cardNotFound: "找不到精选剧情:{cardName}",'; + +// Target locales including zh-CN +const targetLocales = [ + 'zh-CN', 'en', 'zh-TW', 'zh-HK', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'pt', 'ru', + 'it', 'vi', 'th', 'id', 'tr', 'pl', 'nl', 'uk', 'hi', 'cs' +]; + +function addKeyToErrors(content) { + // Check if key already exists + if (content.includes('cardNotFound:')) { + return null; + } + + // Find the errors section and add the key + const errorsPattern = /("errors": \{[^}]*)(\})/; + const match = content.match(errorsPattern); + if (match) { + // Add the new key before the closing brace + const before = match[1]; + const after = match[2]; + // Check if there's already content in errors + if (before.trim().endsWith('{')) { + // Empty errors object, add on new line + return content.replace(errorsPattern, `$1\n${keyToAdd}\n${after}`); + } else { + // Non-empty, add after last key + return content.replace(errorsPattern, `${before},\n${keyToAdd}\n${after}`); + } + } + + // If errors section doesn't exist, we need to create it + // Find "ui" section and add errors after it + const uiPattern = /("ui": \{[^}]*\n[^}]*\})/; + const uiMatch = content.match(uiPattern); + if (uiMatch) { + const uiEnd = uiMatch.index + uiMatch[0].length; + return content.slice(0, uiEnd) + ',\n "errors": {\n' + keyToAdd + '\n }' + content.slice(uiEnd); + } + + return null; +} + +let successCount = 0; +for (const locale of targetLocales) { + try { + const filePath = resolve(localesDir, `${locale}.ts`); + const content = readFileSync(filePath, 'utf-8'); + const newContent = addKeyToErrors(content); + + if (newContent) { + writeFileSync(filePath, newContent); + console.log(`✓ Added cardNotFound to ${locale}.ts`); + successCount++; + } else { + console.log(`- Skipped ${locale}.ts (key already exists)`); + } + } catch (e) { + console.error(`✗ Error updating ${locale}:`, e.message); + } +} + +console.log(`\nDone! Updated ${successCount} locale files with cardNotFound key`); diff --git a/scripts/add-missing-keys.mjs b/scripts/add-missing-keys.mjs new file mode 100644 index 0000000..99055a6 --- /dev/null +++ b/scripts/add-missing-keys.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +// Copy new translation keys from zh-CN to all other locales + +import { readFileSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const localesDir = resolve(__dirname, '../lib/i18n/locales'); + +// Read zh-CN content to extract new keys +const zhCNContent = readFileSync(resolve(localesDir, 'zh-CN.ts'), 'utf-8'); + +// New keys to add (manually extracted from zh-CN.ts) +const newKeysSection = ` + // ========== Play Page (PlayCanvas.tsx & play/page.tsx) ========== + play: { + loading: { + firstFrame: "正 · 在 · 绘 · 制 · 第 · 一 · 幕", + transitioning: "AI · 正 · 在 · 描 · 画 · 下 · 一 · 幕", + visionThinking: "AI · 正 · 在 · 想 · 你 · 看 · 到 · 了 · 什 · 么", + loadingFirst: "正 · 在 · 唤 · 起 · 第 · 一 · 幕", + awakening: "载入中", + }, + + freeform: { + placeholder: "输入你想说的或想做的...", + title: "自由输入", + ariaLabel: "自由输入", + }, + + choiceDisabled: "分享剧情未包含这条分支", + + tooltips: { + openSettings: "打开设置", + openHistory: "剧情回溯", + fullscreen: "全屏 (F)", + enterFullscreen: "进入全屏", + exportGallery: "导出本局为可交互图集链接(含配音;只会保留最近两次的可交互图集链接)", + exportGalleryLabel: "导出可交互图集", + shareStory: "导出本局为可继续游玩的剧情 .infiplot(含配音)", + shareStoryLabel: "分享当前剧情", + mute: "静音", + unmute: "取消静音", + closeNudge: "关闭提示", + silenceNudge: "效果不满意/经常没声音?填入自己的 API Key 试试", + back: "返回", + }, + + imageAlt: "Generated scene", + + counter: { + scene: "第 · {n} · 幕", + beat: "{n} · 拍", + middle: "·", + }, + + buttons: { + fullscreen: "F · 键 · 全 · 屏", + exportGallery: "导 · 出 · 图 · 集", + shareStory: "分 · 享 · 剧 · 情", + muted: "静 · 音", + sound: "有 · 声", + }, + + error: { + title: "出 · 了 · 点 · 状 · 况", + back: "返 · 回", + }, + + previousStep: "上 · 一 · 步 ·", + + settingsFooter: "保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。", + + shareErrors: { + notFound: "没有找到要载入的剧情文件。", + invalid: "剧情分享文件没有可载入的剧情。", + noImage: "剧情分享文件缺少第一幕图片。", + noNextImage: "剧情分享文件缺少下一幕图片。", + noMemory: "剧情分享文件缺少初始剧情记忆,无法载入。", + packFailed: "剧情分享打包失败", + }, + }, +`; + +// Find the line where to insert (before ' language: {' or at end) +function addKeysToFile(content, locale) { + // Check if file already has play section + if (content.includes('play: {')) { + console.log(`${locale} already has play section, skipping`); + return null; + } + + // Find position to insert (before the last ' language:' or before '}') + const langIndex = content.lastIndexOf(' language:'); + if (langIndex > 0) { + return content.slice(0, langIndex) + newKeysSection + content.slice(langIndex); + } + + // If no language: found, find the end of the object + const lastBrace = content.lastIndexOf('}'); + if (lastBrace > 0) { + return content.slice(0, lastBrace) + ',' + newKeysSection + '\n}' + content.slice(lastBrace + 1); + } + + return null; +} + +// Target locales +const targetLocales = [ + 'zh-TW', 'zh-HK', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'pt', 'ru', + 'it', 'vi', 'th', 'id', 'tr', 'pl', 'nl', 'uk', 'hi', 'cs' +]; + +let successCount = 0; +for (const locale of targetLocales) { + try { + const filePath = resolve(localesDir, `${locale}.ts`); + const content = readFileSync(filePath, 'utf-8'); + const newContent = addKeysToFile(content, locale); + + if (newContent) { + writeFileSync(filePath, newContent); + console.log(`✓ Updated ${locale}.ts`); + successCount++; + } + } catch (e) { + console.error(`✗ Error updating ${locale}:`, e.message); + } +} + +console.log(`\nDone! Updated ${successCount} locale files`); +console.log('Note: New keys are in Chinese. Run translation script to translate them.'); diff --git a/scripts/copy-i18n-keys.mjs b/scripts/copy-i18n-keys.mjs new file mode 100644 index 0000000..4791654 --- /dev/null +++ b/scripts/copy-i18n-keys.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +// Simple script to copy missing translation keys from zh-CN to all other locales + +import { readFileSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const localesDir = resolve(__dirname, '../lib/i18n/locales'); + +// Read zh-CN as source (remove comments and export) +function parseLocaleFile(content) { + // Remove comments + let cleaned = content.replace(/\/\/.*$/gm, ''); + // Remove export and type declarations + cleaned = cleaned.replace(/export const \w+ = /, ''); + cleaned = cleaned.replace(/ as const;?.*$/, ''); + cleaned = cleaned.replace(/export type [\s\S]*$/, ''); + // Parse + return JSON.parse(cleaned); +} + +function flattenKeys(obj, prefix = '') { + const keys = {}; + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === 'object' && !Array.isArray(value)) { + Object.assign(keys, flattenKeys(value, fullKey)); + } else { + keys[fullKey] = value; + } + } + return keys; +} + +function setNestedValue(obj, key, value) { + const keys = key.split('.'); + let current = obj; + for (let i = 0; i < keys.length - 1; i++) { + if (!(keys[i] in current)) { + current[keys[i]] = {}; + } + current = current[keys[i]]; + } + current[keys[keys.length - 1]] = value; +} + +// Read zh-CN +let zhCNContent = readFileSync(resolve(localesDir, 'zh-CN.ts'), 'utf-8'); +const zhCN = parseLocaleFile(zhCNContent); +const zhCNKeys = flattenKeys(zhCN); + +// Target locales +const targetLocales = [ + 'zh-TW', 'zh-HK', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'pt', 'ru', + 'it', 'vi', 'th', 'id', 'tr', 'pl', 'nl', 'uk', 'hi', 'cs' +]; + +// Process each locale +for (const locale of targetLocales) { + const filePath = resolve(localesDir, `${locale}.ts`); + try { + let content = readFileSync(filePath, 'utf-8'); + const existing = parseLocaleFile(content); + const existingKeys = flattenKeys(existing); + + // Add missing keys + let added = 0; + for (const [key, value] of Object.entries(zhCNKeys)) { + if (!(key in existingKeys)) { + setNestedValue(existing, key, value); + added++; + } + } + + if (added > 0) { + console.log(`Added ${added} missing keys to ${locale}.ts`); + // Generate new content + const varName = locale.replace('-', '').replace('-', ''); + const typeName = varName.charAt(0).toUpperCase() + varName.slice(1); + const newContent = `// ${locale} - Auto-copied missing keys from zh-CN (fallback) +// Run translation script to translate these keys + +export const ${varName} = ${JSON.stringify(existing, null, 2)} as const; + +export type ${typeName}Translations = typeof ${varName}; +`; + writeFileSync(filePath, newContent); + } + } catch (e) { + console.error(`Error processing ${locale}:`, e.message); + } +} + +console.log('Done copying missing keys to all locales'); diff --git a/scripts/fix-hint-icu-v2.mjs b/scripts/fix-hint-icu-v2.mjs new file mode 100644 index 0000000..f723938 --- /dev/null +++ b/scripts/fix-hint-icu-v2.mjs @@ -0,0 +1,203 @@ +#!/usr/bin/env node +// Fix ICU MessageFormat syntax in hint.text across all locales - v2 + +import { readFileSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const localesDir = resolve(__dirname, '../lib/i18n/locales'); + +// Function translations for each locale +const hintTranslations = { + 'zh-TW': { + text: (params) => { + const authHint = params.authEnabled ? '(測試期間,登入即可免費暢玩)' : ''; + return `輸入想法、配置風格,點擊「開始」即可遊玩${authHint};也可以從下方精選故事集挑一篇快速體驗 InfiPlot。點擊「設置」還能填入你的名字,以及你自己的文本、繪圖、識圖模型和配音 Key——全部只存在本地瀏覽器,體驗更穩定。`; + }, + closeAriaLabel: "不再顯示此提示", + }, + 'zh-HK': { + text: (params) => { + const authHint = params.authEnabled ? '(測試期間,登入即可免費暢玩)' : ''; + return `輸入想法、配置風格,點擊「開始」即可遊玩${authHint};也可以從下方精選故事集挑一篇快速體驗 InfiPlot。點擊「設置」還能填入你的名字,以及你自己的文本、繪圖、識圖模型和配音 Key——全部只存在本地瀏覽器,體驗更穩定。`; + }, + closeAriaLabel: "不再顯示此提示", + }, + 'ja': { + text: (params) => { + const authHint = params.authEnabled ? '(ベータ期間中、ログインで無料プレイ)' : ''; + return `アイデアを入力し、スタイルを設定して「開始」をクリックしてプレイ${authHint}。または、下の厳選ストーリーから1つを選んで、InfiPlotを素早く体験することもできます。「設定」をクリックして、自分の名前とテキスト、画像、ビジョンモデル、TTSキーを入力できます—すべてブラウザにローカル保存され、より安定した体験が得られます。`; + }, + closeAriaLabel: "このヒントを再度表示しない", + }, + 'ko': { + text: (params) => { + const authHint = params.authEnabled ? '(베타 기간 중, 로그인하면 무료 플레이)' : ''; + return `아이디어를 입력하고 스타일을 구성한 후 "시작"을 클릭하여 플레이${authHint}. 또는 아래의 큐레이션된 스토리 중 하나를 선택하여 InfiPlot을 빠르게 경험할 수도 있습니다. "설정"을 클릭하여 이름과 텍스트, 이미지, 비전 모델, TTS 키를 입력할 수 있습니다—모두 브라우저에 로컬로 저장되어 더 안정적인 경험을 제공합니다.`; + }, + closeAriaLabel: "이 힌트를 다시 표시하지 않음", + }, + 'es': { + text: (params) => { + const authHint = params.authEnabled ? ' (se requiere inicio de sesión durante la beta, juego gratuito)' : ''; + return `Ingresa tus ideas, configura estilos y haz clic en "Iniciar" para jugar${authHint}. También puedes elegir una historia curada de abajo para experimentar rápidamente InfiPlot. Haz clic en "Configuración" para ingresar tu nombre y configurar tus propias claves de texto, imagen, visión y TTS—todo almacenado localmente en tu navegador para una experiencia más estable.`; + }, + closeAriaLabel: "No volver a mostrar este consejo", + }, + 'fr': { + text: (params) => { + const authHint = params.authEnabled ? ' (connexion requise pendant la bêta, jeu gratuit)' : ''; + return `Entrez vos idées, configurez les styles et cliquez sur "Démarrer" pour jouer${authHint}. Vous pouvez également choisir une histoire sélectionnée ci-dessous pour découvrir rapidement InfiPlot. Cliquez sur "Paramètres" pour entrer votre nom et configurer vos propres clés de texte, d'image, de vision et de TTS—tout est stocké localement dans votre navigateur pour une expérience plus stable.`; + }, + closeAriaLabel: "Ne plus afficher cette astuce", + }, + 'de': { + text: (params) => { + const authHint = params.authEnabled ? ' (Anmeldung während der Beta erforderlich, kostenloses Spielen)' : ''; + return `Gib deine Ideen ein, konfiguriere Stile und klicke auf "Starten" zum Spielen${authHint}. Du kannst auch eine kuratierte Geschichte unten auswählen, um InfiPlot schnell zu erleben. Klicke auf "Einstellungen", um deinen Namen einzugeben und deine eigenen Text-, Bild-, Vision- und TTS-Schlüssel zu konfigurieren—alles wird lokal in deinem Browser für eine stabilere Erfahrung gespeichert.`; + }, + closeAriaLabel: "Diesen Hinweis nicht mehr anzeigen", + }, + 'pt-BR': { + text: (params) => { + const authHint = params.authEnabled ? ' (login necessário durante o beta, grátis para jogar)' : ''; + return `Digite suas ideias, configure estilos e clique em "Iniciar" para jogar${authHint}. Você também pode escolher uma história curada abaixo para experimentar rapidamente InfiPlot. Clique em "Configurações" para inserir seu nome e configurar suas próprias chaves de texto, imagem, visão e TTS—tudo armazenado localmente no seu navegador para uma experiência mais estável.`; + }, + closeAriaLabel: "Não mostrar mais este aviso", + }, + 'pt': { + text: (params) => { + const authHint = params.authEnabled ? ' (login necessário durante o beta, grátis para jogar)' : ''; + return `Digite as suas ideias, configure estilos e clique em "Iniciar" para jogar${authHint}. Também pode escolher uma história curada abaixo para experimentar rapidamente InfiPlot. Clique em "Configurações" para inserir o seu nome e configurar as suas próprias chaves de texto, imagem, visão e TTS—tudo guardado localmente no seu navegador para uma experiência mais estável.`; + }, + closeAriaLabel: "Não mostrar esta dica novamente", + }, + 'ru': { + text: (params) => { + const authHint = params.authEnabled ? ' (требуется вход во время бета-теста, бесплатная игра)' : ''; + return `Введите свои идеи, настройте стили и нажмите "Начать" для игры${authHint}. Вы также можете выбрать выбранную историю ниже, чтобы быстро испытать InfiPlot. Нажмите "Настройки", чтобы ввести свое имя и настроить свои собственные ключи текста, изображения, зрения и TTS—все сохраняется локально в вашем браузере для более стабильного опыта.`; + }, + closeAriaLabel: "Больше не показывать эту подсказку", + }, + 'it': { + text: (params) => { + const authHint = params.authEnabled ? ' (accesso richiesto durante la beta, gioco gratuito)' : ''; + return `Inserisci le tue idee, configura gli stili e fai clic su "Inizia" per giocare${authHint}. Puoi anche scegliere una storia curata qui sotto per provare rapidamente InfiPlot. Fai clic su "Impostazioni" per inserire il tuo nome e configurare le tue chiavi di testo, immagine, visione e TTS—tutto salvato localmente nel tuo browser per un'esperienza più stabile.`; + }, + closeAriaLabel: "Non mostrare più questo suggerimento", + }, + 'vi': { + text: (params) => { + const authHint = params.authEnabled ? ' (yêu cầu đăng nhập trong bản beta, chơi miễn phí)' : ''; + return `Nhập ý tưởng của bạn, cấu hình kiểu và nhấp "Bắt đầu" để chơi${authHint}. Bạn cũng có thể chọn một câu chuyện được chọn từ bên dưới để trải nghiệm nhanh InfiPlot. Nhấp "Cài đặt" để nhập tên của bạn và cấu hình khóa văn bản, hình ảnh, hình ảnh và TTS của riêng bạn—tất cả được lưu cục bộ trong trình duyệt của bạn để có trải nghiệm ổn định hơn.`; + }, + closeAriaLabel: "Không còn hiển thị gợi ý này", + }, + 'th': { + text: (params) => { + const authHint = params.authEnabled ? ' (ต้องล็อกอินระหว่างเบต้า, เล่นฟรี)' : ''; + return `ป้อนแนวคิดของคุณ กำหนดค่าสไตล์ และคลิก "เริ่ม" เพื่อเล่น${authHint} คุณยังสามารถเลือกเรื่องราวที่คัดสรรจากด้านล่างเพื่อสัมผัส InfiPlot ได้อย่างรวดเร็ว คลิก "การตั้งค่า" เพื่อป้อนชื่อและกำหนดค่าคีย์ข้อความ รูปภาพ การมองเห็น และ TTS ของคุณเอง—ทั้งหมดจะถูกเก็บไว้ในเบราว์เซอร์ของคุณเพื่อประสบการณ์ที่มีเสถียรภาพมากขึ้น`; + }, + closeAriaLabel: "ไม่แสดงคำแนะนำนี้อีก", + }, + 'id': { + text: (params) => { + const authHint = params.authEnabled ? ' (login diperlukan selama beta, main gratis)' : ''; + return `Masukkan ide Anda, konfigurasi gaya, dan klik "Mulai" untuk bermain${authHint}. Anda juga dapat memilih cerita kurasi dari bawah untuk pengalaman cepat InfiPlot. Klik "Pengaturan" untuk memasukkan nama Anda dan mengonfigurasi kunci teks, gambar, visi, dan TTS Anda sendiri—semua disimpan secara lokal di browser Anda untuk pengalaman yang lebih stabil.`; + }, + closeAriaLabel: "Jangan tampilkan petunjuk ini lagi", + }, + 'tr': { + text: (params) => { + const authHint = params.authEnabled ? ' (beta sırasında giriş gerekli, ücretsiz oyun)' : ''; + return `Fikirlerinizi girin, stilleri yapılandırın ve oynamak için "Başlat"a tıklayın${authHint}. Aşağıdan küratörlü bir hikaye seçerek InfiPlot'ı hızlıca deneyimleyebilirsiniz. "Ayarlar"a tıklayarak adınızı girebilir ve kendi metin, resim, görü ve TTS anahtarlarınızı yapılandırabilirsiniz—tümü daha stabil bir deneyim için tarayıcınızda yerel olarak saklanır.`; + }, + closeAriaLabel: "Bu ipucunu bir daha gösterme", + }, + 'pl': { + text: (params) => { + const authHint = params.authEnabled ? ' (wymagane logowanie podczas beta, darmowa gra)' : ''; + return `Wprowadź swoje pomysły, skonfiguruj style i kliknij "Rozpocznij", aby zagrać${authHint}. Możesz także wybrać kuratorską historię z dołu, aby szybko doświadczyć InfiPlot. Kliknij "Ustawienia", aby wprowadzić swoje imię i skonfigurować własne klucze tekstu, obrazu, widoku i TTS—wszystko przechowywane lokalnie w twojej przeglądarce dla bardziej stabilnego doświadczenia.`; + }, + closeAriaLabel: "Nie pokazuj więcej tej podpowiedzi", + }, + 'nl': { + text: (params) => { + const authHint = params.authEnabled ? ' (inloggen vereist tijdens beta, gratis spelen)' : ''; + return `Voer je ideeën in, configureer stijlen en klik op "Starten" om te spelen${authHint}. Je kunt ook een gecureerd verhaal onderaan kiezen om InfiPlot snel te ervaren. Klik op "Instellingen" om je naam in te voeren en je eigen tekst-, afbeeldings-, visie- en TTS-sleutels te configureren—alles lokaal in je browser opgeslagen voor een stabielere ervaring.`; + }, + closeAriaLabel: "Deze hint niet meer weergeven", + }, + 'uk': { + text: (params) => { + const authHint = params.authEnabled ? ' (вхід потрібен під час бета-тестування, безкоштовна гра)' : ''; + return `Введіть свої ідеї, налаштуйте стилі та натисніть "Почати" для гри${authHint}. Ви також можете обрати вибрану історію знизу, щоб швидко випробувати InfiPlot. Натисніть "Налаштування", щоб ввести своє ім'я та налаштувати власні ключі тексту, зображення, зору та TTS—все зберігається локально у вашому браузері для стабільнішого досвіду.`; + }, + closeAriaLabel: "Більше не показувати цю підказку", + }, + 'hi': { + text: (params) => { + const authHint = params.authEnabled ? ' (बीटा के दौरान लॉगिन आवश्यक, मुफ्त खेल)' : ''; + return `अपने विचार दर्ज करें, शैलियों को कॉन्फ़िगर करें और खेलने के लिए "शुरू" क्लिक करें${authHint}। आप नीचे से एक क्यूरेटेड कहानी चुनकर InfiPlot का तेजी से अनुभव भी कर सकते हैं। "सेटिंग्स" पर क्लिक करें अपना नाम दर्ज करने और अपनी टेक्स्ट, इमेज, विजन और TTS कुंजियों को कॉन्फ़िगर करने के लिए—सब कुछ अधिक स्थिर अनुभव के लिए आपके ब्राउज़र में स्थानीय रूप से संग्रहीत है।`; + }, + closeAriaLabel: "यह संकेत फिर न दिखाएं", + }, + 'cs': { + text: (params) => { + const authHint = params.authEnabled ? ' (během bety vyžadováno přihlášení, hra zdarma)' : ''; + return `Zadejte své nápady, nakonfigurujte styly a klikněte na "Spustit" pro hraní${authHint}. Můžete si také vybrat kurátorskou příběh z níže pro rychlé zážitky InfiPlot. Klikněte na "Nastavení" pro zadání vašeho jména a konfiguraci vlastních klíčů pro text, obrázky, vizi a TTS—vše uloženo lokálně ve vašem prohlížeči pro stabilnější zážitek.`; + }, + closeAriaLabel: "Znovu nezobrazovat tuto nápovědu", + }, +}; + +// Target locales - the ones that still need fixing +const targetLocales = ['pt-BR', 'id', 'tr']; + +function fixHintText(content, locale) { + const translation = hintTranslations[locale]; + if (!translation) { + console.log(` No translation for ${locale}`); + return null; + } + + // The replacement hint object + const replacement = `"hint": { + "text": ${translation.text.toString().replace(/\n/g, '\n ')}, + "closeAriaLabel": "${translation.closeAriaLabel}" + }`; + + // Try to find and replace the hint section + // Pattern: "hint": { "text": "...", "closeAriaLabel": "..." } + // This handles multi-line strings with escaped quotes + const hintPattern = /"hint":\s*\{\s*"text":\s*"[^]*",\s*"closeAriaLabel":\s*"[^"]*"\s*\}/; + + if (hintPattern.test(content)) { + return content.replace(hintPattern, replacement); + } + + console.log(` No matching hint pattern found in ${locale}.ts`); + return null; +} + +let successCount = 0; +for (const locale of targetLocales) { + try { + const filePath = resolve(localesDir, `${locale}.ts`); + const content = readFileSync(filePath, 'utf-8'); + const newContent = fixHintText(content, locale); + + if (newContent && newContent !== content) { + writeFileSync(filePath, newContent); + console.log(`✓ Fixed ${locale}.ts`); + successCount++; + } else { + console.log(`- Skipped ${locale}.ts`); + } + } catch (e) { + console.error(`✗ Error updating ${locale}:`, e.message); + } +} + +console.log(`\nDone! Fixed ${successCount} locale files`); diff --git a/scripts/fix-hint-icu.mjs b/scripts/fix-hint-icu.mjs new file mode 100644 index 0000000..7d7cbb8 --- /dev/null +++ b/scripts/fix-hint-icu.mjs @@ -0,0 +1,217 @@ +#!/usr/bin/env node +// Fix ICU MessageFormat syntax in hint.text across all locales + +import { readFileSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const localesDir = resolve(__dirname, '../lib/i18n/locales'); + +// Function translations for each locale +const hintTranslations = { + 'zh-TW': { + text: (params) => { + const authHint = params.authEnabled ? '(測試期間,登入即可免費暢玩)' : ''; + return `輸入想法、配置風格,點擊「開始」即可遊玩${authHint};也可以從下方精選故事集挑一篇快速體驗 InfiPlot。點擊「設置」還能填入你的名字,以及你自己的文本、繪圖、識圖模型和配音 Key——全部只存在本地瀏覽器,體驗更穩定。`; + }, + closeAriaLabel: "不再顯示此提示", + }, + 'zh-HK': { + text: (params) => { + const authHint = params.authEnabled ? '(測試期間,登入即可免費暢玩)' : ''; + return `輸入想法、配置風格,點擊「開始」即可遊玩${authHint};也可以從下方精選故事集挑一篇快速體驗 InfiPlot。點擊「設置」還能填入你的名字,以及你自己的文本、繪圖、識圖模型和配音 Key——全部只存在本地瀏覽器,體驗更穩定。`; + }, + closeAriaLabel: "不再顯示此提示", + }, + 'ja': { + text: (params) => { + const authHint = params.authEnabled ? '(ベータ期間中、ログインで無料プレイ)' : ''; + return `アイデアを入力し、スタイルを設定して「開始」をクリックしてプレイ${authHint}。または、下の厳選ストーリーから1つを選んで、InfiPlotを素早く体験することもできます。「設定」をクリックして、自分の名前とテキスト、画像、ビジョンモデル、TTSキーを入力できます—すべてブラウザにローカル保存され、より安定した体験が得られます。`; + }, + closeAriaLabel: "このヒントを再度表示しない", + }, + 'ko': { + text: (params) => { + const authHint = params.authEnabled ? '(베타 기간 중, 로그인하면 무료 플레이)' : ''; + return `아이디어를 입력하고 스타일을 구성한 후 "시작"을 클릭하여 플레이${authHint}. 또는 아래의 큐레이션된 스토리 중 하나를 선택하여 InfiPlot을 빠르게 경험할 수도 있습니다. "설정"을 클릭하여 이름과 텍스트, 이미지, 비전 모델, TTS 키를 입력할 수 있습니다—모두 브라우저에 로컬로 저장되어 더 안정적인 경험을 제공합니다.`; + }, + closeAriaLabel: "이 힌트를 다시 표시하지 않음", + }, + 'es': { + text: (params) => { + const authHint = params.authEnabled ? ' (se requiere inicio de sesión durante la beta, juego gratuito)' : ''; + return `Ingresa tus ideas, configura estilos y haz clic en "Iniciar" para jugar${authHint}. También puedes elegir una historia curada de abajo para experimentar rápidamente InfiPlot. Haz clic en "Configuración" para ingresar tu nombre y configurar tus propias claves de texto, imagen, visión y TTS—todo almacenado localmente en tu navegador para una experiencia más estable.`; + }, + closeAriaLabel: "No volver a mostrar este consejo", + }, + 'fr': { + text: (params) => { + const authHint = params.authEnabled ? ' (connexion requise pendant la bêta, jeu gratuit)' : ''; + return `Entrez vos idées, configurez les styles et cliquez sur "Démarrer" pour jouer${authHint}. Vous pouvez également choisir une histoire sélectionnée ci-dessous pour découvrir rapidement InfiPlot. Cliquez sur "Paramètres" pour entrer votre nom et configurer vos propres clés de texte, d'image, de vision et de TTS—tout est stocké localement dans votre navigateur pour une expérience plus stable.`; + }, + closeAriaLabel: "Ne plus afficher cette astuce", + }, + 'de': { + text: (params) => { + const authHint = params.authEnabled ? ' (Anmeldung während der Beta erforderlich, kostenloses Spielen)' : ''; + return `Gib deine Ideen ein, konfiguriere Stile und klicke auf "Starten" zum Spielen${authHint}. Du kannst auch eine kuratierte Geschichte unten auswählen, um InfiPlot schnell zu erleben. Klicke auf "Einstellungen", um deinen Namen einzugeben und deine eigenen Text-, Bild-, Vision- und TTS-Schlüssel zu konfigurieren—alles wird lokal in deinem Browser für eine stabilere Erfahrung gespeichert.`; + }, + closeAriaLabel: "Diesen Hinweis nicht mehr anzeigen", + }, + 'pt-BR': { + text: (params) => { + const authHint = params.authEnabled ? ' (login necessário durante o beta, grátis para jogar)' : ''; + return `Digite suas ideias, configure estilos e clique em "Iniciar" para jogar${authHint}. Você também pode escolher uma história curada abaixo para experimentar rapidamente InfiPlot. Clique em "Configurações" para inserir seu nome e configurar suas próprias chaves de texto, imagem, visão e TTS—tudo armazenado localmente no seu navegador para uma experiência mais estável.`; + }, + closeAriaLabel: "Não mostrar esta dica novamente", + }, + 'pt': { + text: (params) => { + const authHint = params.authEnabled ? ' (login necessário durante o beta, grátis para jogar)' : ''; + return `Digite as suas ideias, configure estilos e clique em "Iniciar" para jogar${authHint}. Também pode escolher uma história curada abaixo para experimentar rapidamente InfiPlot. Clique em "Configurações" para inserir o seu nome e configurar as suas próprias chaves de texto, imagem, visão e TTS—tudo guardado localmente no seu navegador para uma experiência mais estável.`; + }, + closeAriaLabel: "Não mostrar esta dica novamente", + }, + 'ru': { + text: (params) => { + const authHint = params.authEnabled ? ' (требуется вход во время бета-теста, бесплатная игра)' : ''; + return `Введите свои идеи, настройте стили и нажмите "Начать" для игры${authHint}. Вы также можете выбрать выбранную историю ниже, чтобы быстро испытать InfiPlot. Нажмите "Настройки", чтобы ввести свое имя и настроить свои собственные ключи текста, изображения, зрения и TTS—все сохраняется локально в вашем браузере для более стабильного опыта.`; + }, + closeAriaLabel: "Больше не показывать эту подсказку", + }, + 'it': { + text: (params) => { + const authHint = params.authEnabled ? ' (accesso richiesto durante la beta, gioco gratuito)' : ''; + return `Inserisci le tue idee, configura gli stili e fai clic su "Inizia" per giocare${authHint}. Puoi anche scegliere una storia curata qui sotto per provare rapidamente InfiPlot. Fai clic su "Impostazioni" per inserire il tuo nome e configurare le tue chiavi di testo, immagine, visione e TTS—tutto salvato localmente nel tuo browser per un'esperienza più stabile.`; + }, + closeAriaLabel: "Non mostrare più questo suggerimento", + }, + 'vi': { + text: (params) => { + const authHint = params.authEnabled ? ' (yêu cầu đăng nhập trong bản beta, chơi miễn phí)' : ''; + return `Nhập ý tưởng của bạn, cấu hình kiểu và nhấp "Bắt đầu" để chơi${authHint}. Bạn cũng có thể chọn một câu chuyện được chọn từ bên dưới để trải nghiệm nhanh InfiPlot. Nhấp "Cài đặt" để nhập tên của bạn và cấu hình khóa văn bản, hình ảnh, hình ảnh và TTS của riêng bạn—tất cả được lưu cục bộ trong trình duyệt của bạn để có trải nghiệm ổn định hơn.`; + }, + closeAriaLabel: "Không còn hiển thị gợi ý này", + }, + 'th': { + text: (params) => { + const authHint = params.authEnabled ? ' (ต้องล็อกอินระหว่างเบต้า, เล่นฟรี)' : ''; + return `ป้อนแนวคิดของคุณ กำหนดค่าสไตล์ และคลิก "เริ่ม" เพื่อเล่น${authHint} คุณยังสามารถเลือกเรื่องราวที่คัดสรรจากด้านล่างเพื่อสัมผัส InfiPlot ได้อย่างรวดเร็ว คลิก "การตั้งค่า" เพื่อป้อนชื่อและกำหนดค่าคีย์ข้อความ รูปภาพ การมองเห็น และ TTS ของคุณเอง—ทั้งหมดจะถูกเก็บไว้ในเบราว์เซอร์ของคุณเพื่อประสบการณ์ที่มีเสถียรภาพมากขึ้น`; + }, + closeAriaLabel: "ไม่แสดงคำแนะนำนี้อีก", + }, + 'id': { + text: (params) => { + const authHint = params.authEnabled ? ' (login diperlukan selama beta, main gratis)' : ''; + return `Masukkan ide Anda, konfigurasi gaya, dan klik "Mulai" untuk bermain${authHint}. Anda juga dapat memilih cerita kurasi dari bawah untuk pengalaman cepat InfiPlot. Klik "Pengaturan" untuk memasukkan nama Anda dan mengonfigurasi kunci teks, gambar, visi, dan TTS Anda sendiri—semua disimpan secara lokal di browser Anda untuk pengalaman yang lebih stabil.`; + }, + closeAriaLabel: "Jangan tampilkan petunjuk ini lagi", + }, + 'tr': { + text: (params) => { + const authHint = params.authEnabled ? ' (beta sırasında giriş gerekli, ücretsiz oyun)' : ''; + return `Fikirlerinizi girin, stilleri yapılandırın ve oynamak için "Başlat"a tıklayın${authHint}. Aşağıdan küratörlü bir hikaye seçerek InfiPlot'ı hızlıca deneyimleyebilirsiniz. "Ayarlar"a tıklayarak adınızı girebilir ve kendi metin, resim, görü ve TTS anahtarlarınızı yapılandırabilirsiniz—tümü daha stabil bir deneyim için tarayıcınızda yerel olarak saklanır.`; + }, + closeAriaLabel: "Bu ipucunu bir daha gösterme", + }, + 'pl': { + text: (params) => { + const authHint = params.authEnabled ? ' (wymagane logowanie podczas beta, darmowa gra)' : ''; + return `Wprowadź swoje pomysły, skonfiguruj style i kliknij "Rozpocznij", aby zagrać${authHint}. Możesz także wybrać kuratorską historię z dołu, aby szybko doświadczyć InfiPlot. Kliknij "Ustawienia", aby wprowadzić swoje imię i skonfigurować własne klucze tekstu, obrazu, widoku i TTS—wszystko przechowywane lokalnie w twojej przeglądarce dla bardziej stabilnego doświadczenia.`; + }, + closeAriaLabel: "Nie pokazuj więcej tej podpowiedzi", + }, + 'nl': { + text: (params) => { + const authHint = params.authEnabled ? ' (inloggen vereist tijdens beta, gratis spelen)' : ''; + return `Voer je ideeën in, configureer stijlen en klik op "Starten" om te spelen${authHint}. Je kunt ook een gecureerd verhaal onderaan kiezen om InfiPlot snel te ervaren. Klik op "Instellingen" om je naam in te voeren en je eigen tekst-, afbeeldings-, visie- en TTS-sleutels te configureren—alles lokaal in je browser opgeslagen voor een stabielere ervaring.`; + }, + closeAriaLabel: "Deze hint niet meer weergeven", + }, + 'uk': { + text: (params) => { + const authHint = params.authEnabled ? ' (вхід потрібен під час бета-тестування, безкоштовна гра)' : ''; + return `Введіть свої ідеї, налаштуйте стилі та натисніть "Почати" для гри${authHint}. Ви також можете обрати вибрану історію знизу, щоб швидко випробувати InfiPlot. Натисніть "Налаштування", щоб ввести своє ім'я та налаштувати власні ключі тексту, зображення, зору та TTS—все зберігається локально у вашому браузері для стабільнішого досвіду.`; + }, + closeAriaLabel: "Більше не показувати цю підказку", + }, + 'hi': { + text: (params) => { + const authHint = params.authEnabled ? ' (बीटा के दौरान लॉगिन आवश्यक, मुफ्त खेल)' : ''; + return `अपने विचार दर्ज करें, शैलियों को कॉन्फ़िगर करें और खेलने के लिए "शुरू" क्लिक करें${authHint}। आप नीचे से एक क्यूरेटेड कहानी चुनकर InfiPlot का तेजी से अनुभव भी कर सकते हैं। "सेटिंग्स" पर क्लिक करें अपना नाम दर्ज करने और अपनी टेक्स्ट, इमेज, विजन और TTS कुंजियों को कॉन्फ़िगर करने के लिए—सब कुछ अधिक स्थिर अनुभव के लिए आपके ब्राउज़र में स्थानीय रूप से संग्रहीत है।`; + }, + closeAriaLabel: "यह संकेत फिर न दिखाएं", + }, + 'cs': { + text: (params) => { + const authHint = params.authEnabled ? ' (během bety vyžadováno přihlášení, hra zdarma)' : ''; + return `Zadejte své nápady, nakonfigurujte styly a klikněte na "Spustit" pro hraní${authHint}. Můžete si také vybrat kurátorskou příběh z níže pro rychlé zážitky InfiPlot. Klikněte na "Nastavení" pro zadání vašeho jména a konfiguraci vlastních klíčů pro text, obrázky, vizi a TTS—vše uloženo lokálně ve vašem prohlížeči pro stabilnější zážitek.`; + }, + closeAriaLabel: "Znovu nezobrazovat tuto nápovědu", + }, +}; + +// Target locales +const targetLocales = [ + 'zh-TW', 'zh-HK', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'pt', 'ru', + 'it', 'vi', 'th', 'id', 'tr', 'pl', 'nl', 'uk', 'hi', 'cs' +]; + +function fixHintText(content, locale) { + const translation = hintTranslations[locale]; + if (!translation) return null; + + // Pattern handles both hint: { and "hint": { (quoted keys) + // The ICU syntax can be {authEnabled...} or {{authEnabled...}} + const textPattern = /"text":\s*"[^"]*\{?authEnabled/; + + // Build the replacement - handle both quoted and unquoted keys + const usesQuotedKeys = content.includes('"hint":'); + const hintKey = usesQuotedKeys ? '"hint"' : 'hint'; + const textKey = usesQuotedKeys ? '"text"' : 'text'; + const closeLabelKey = usesQuotedKeys ? '"closeAriaLabel"' : 'closeAriaLabel'; + + const replacement = `${hintKey}: { + ${textKey}: ${translation.text.toString().replace(/\n/g, '\n ')}, + ${closeLabelKey}: "${translation.closeAriaLabel}" + }`; + + // Check for ICU syntax first + if (textPattern.test(content)) { + // Replace the entire hint section with ICU syntax + const fullHintPattern = /"hint":\s*\{[^}]*"text":\s*"[^"]*"[^}]*"closeAriaLabel":\s*"[^"]*"\s*\}/; + return content.replace(fullHintPattern, replacement); + } + + // Check for empty hint object + const emptyHintPattern = /"hint":\s*\{\s*\}/; + if (emptyHintPattern.test(content)) { + console.log(` Found empty hint object in ${locale}.ts, replacing`); + return content.replace(emptyHintPattern, replacement); + } + + console.log(` No ICU syntax or empty hint found in ${locale}.ts`); + return null; +} + +let successCount = 0; +for (const locale of targetLocales) { + try { + const filePath = resolve(localesDir, `${locale}.ts`); + const content = readFileSync(filePath, 'utf-8'); + const newContent = fixHintText(content, locale); + + if (newContent && newContent !== content) { + writeFileSync(filePath, newContent); + console.log(`✓ Fixed ${locale}.ts`); + successCount++; + } else if (!newContent) { + console.log(`- Skipped ${locale}.ts (no ICU syntax found)`); + } + } catch (e) { + console.error(`✗ Error updating ${locale}:`, e.message); + } +} + +console.log(`\nDone! Fixed ${successCount} locale files`); diff --git a/scripts/fix-locale-syntax.mjs b/scripts/fix-locale-syntax.mjs new file mode 100644 index 0000000..23e0fa5 --- /dev/null +++ b/scripts/fix-locale-syntax.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +// Fix syntax errors in locale files (remove extra comma before play section) + +import { readFileSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const localesDir = resolve(__dirname, '../lib/i18n/locales'); + +// Fix the pattern: }\n, // should be }\n\n +function fixLocaleFile(content) { + // Replace the pattern where language closing is followed by comma and then play section + return content.replace( + /}\s*,\s*\/\/ ======== Play Page ========/g, + '},\n // ========== Play Page ==========' + ); +} + +// All locales with the issue +const targetLocales = [ + 'zh-TW', 'zh-HK', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'pt', 'ru', + 'it', 'vi', 'th', 'id', 'tr', 'pl', 'nl', 'uk', 'hi', 'cs' +]; + +for (const locale of targetLocales) { + try { + const filePath = resolve(localesDir, `${locale}.ts`); + const content = readFileSync(filePath, 'utf-8'); + const newContent = fixLocaleFile(content); + + if (newContent !== content) { + writeFileSync(filePath, newContent); + console.log(`✓ Fixed ${locale}.ts`); + } + } catch (e) { + console.error(`✗ Error fixing ${locale}:`, e.message); + } +} + +console.log('Done! Fixed locale files'); diff --git a/scripts/fix-param-types.mjs b/scripts/fix-param-types.mjs new file mode 100644 index 0000000..6209fb1 --- /dev/null +++ b/scripts/fix-param-types.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +// Fix type annotations for params parameter in locale files + +import { readFileSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const localesDir = resolve(__dirname, '../lib/i18n/locales'); + +// Target locales +const targetLocales = [ + 'de', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt-BR', 'pt', + 'ru', 'th', 'tr', 'uk', 'zh-TW', 'zh-HK' +]; + +function fixParamsType(content) { + // Replace (params) => with (params: { authEnabled?: boolean }) => + return content.replace( + /\(params\)\s*=>\s*\{/g, + '(params: { authEnabled?: boolean }) => {' + ); +} + +let successCount = 0; +for (const locale of targetLocales) { + try { + const filePath = resolve(localesDir, `${locale}.ts`); + const content = readFileSync(filePath, 'utf-8'); + const newContent = fixParamsType(content); + + if (newContent !== content) { + writeFileSync(filePath, newContent); + console.log(`✓ Fixed ${locale}.ts`); + successCount++; + } + } catch (e) { + console.error(`✗ Error updating ${locale}:`, e.message); + } +} + +console.log(`\nDone! Fixed ${successCount} locale files`); diff --git a/scripts/rebuild-locales.mjs b/scripts/rebuild-locales.mjs new file mode 100644 index 0000000..c3aa02f --- /dev/null +++ b/scripts/rebuild-locales.mjs @@ -0,0 +1,299 @@ +#!/usr/bin/env node +// Rebuild all locale files from zh-CN template + +import { readFileSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const localesDir = resolve(__dirname, '../lib/i18n/locales'); + +// Read zh-CN as template +const zhCNContent = readFileSync(resolve(localesDir, 'zh-CN.ts'), 'utf-8'); + +// Function translations for hint.text in each locale +const hintTranslations = { + 'en': { + text: (params) => { + const authHint = params.authEnabled ? ' (login required during beta, free to play)' : ''; + return `Enter your ideas, configure styles, and click "Start" to play${authHint}. You can also pick a curated story from below to quickly experience InfiPlot. Click "Settings" to enter your name and configure your own text, image, vision models and TTS keys—all stored locally in your browser for a more stable experience.`; + }, + closeAriaLabel: "Don't show this hint again", + }, + 'zh-TW': { + text: (params) => { + const authHint = params.authEnabled ? '(測試期間,登入即可免費暢玩)' : ''; + return `輸入想法、配置風格,點擊「開始」即可遊玩${authHint};也可以從下方精選故事集挑一篇快速體驗 InfiPlot。點擊「設置」還能填入你的名字,以及你自己的文本、繪圖、識圖模型和配音 Key——全部只存在本地瀏覽器,體驗更穩定。`; + }, + closeAriaLabel: "不再顯示此提示", + }, + 'zh-HK': { + text: (params) => { + const authHint = params.authEnabled ? '(測試期間,登入即可免費暢玩)' : ''; + return `輸入想法、配置風格,點擊「開始」即可遊玩${authHint};也可以從下方精選故事集挑一篇快速體驗 InfiPlot。點擊「設置」還能填入你的名字,以及你自己的文本、繪圖、識圖模型和配音 Key——全部只存在本地瀏覽器,體驗更穩定。`; + }, + closeAriaLabel: "不再顯示此提示", + }, + 'ja': { + text: (params) => { + const authHint = params.authEnabled ? '(ベータ期間中、ログインで無料プレイ)' : ''; + return `アイデアを入力し、スタイルを設定して「開始」をクリックしてプレイ${authHint}。または、下の厳選ストーリーから1つを選んで、InfiPlotを素早く体験することもできます。「設定」をクリックして、自分の名前とテキスト、画像、ビジョンモデル、TTSキーを入力できます—すべてブラウザにローカル保存され、より安定した体験が得られます。`; + }, + closeAriaLabel: "このヒントを再度表示しない", + }, + 'ko': { + text: (params) => { + const authHint = params.authEnabled ? '(베타 기간 중, 로그인하면 무료 플레이)' : ''; + return `아이디어를 입력하고 스타일을 구성한 후 "시작"을 클릭하여 플레이${authHint}. 또는 아래의 큐레이션된 스토리 중 하나를 선택하여 InfiPlot을 빠르게 경험할 수도 있습니다. "설정"을 클릭하여 이름과 텍스트, 이미지, 비전 모델, TTS 키를 입력할 수 있습니다—모두 브라우저에 로컬로 저장되어 더 안정적인 경험을 제공합니다.`; + }, + closeAriaLabel: "이 힌트를 다시 표시하지 않음", + }, + 'es': { + text: (params) => { + const authHint = params.authEnabled ? ' (se requiere inicio de sesión durante la beta, juego gratuito)' : ''; + return `Ingresa tus ideas, configura estilos y haz clic en "Iniciar" para jugar${authHint}. También puedes elegir una historia curada de abajo para experimentar rápidamente InfiPlot. Haz clic en "Configuración" para ingresar tu nombre y configurar tus propias claves de texto, imagen, visión y TTS—todo almacenado localmente en tu navegador para una experiencia más estable.`; + }, + closeAriaLabel: "No volver a mostrar este consejo", + }, + 'fr': { + text: (params) => { + const authHint = params.authEnabled ? ' (connexion requise pendant la bêta, jeu gratuit)' : ''; + return `Entrez vos idées, configurez les styles et cliquez sur "Démarrer" pour jouer${authHint}. Vous pouvez également choisir une histoire sélectionnée ci-dessous pour découvrir rapidement InfiPlot. Cliquez sur "Paramètres" pour entrer votre nom et configurer vos propres clés de texte, d'image, de vision et de TTS—tout est stocké localement dans votre navigateur pour une expérience plus stable.`; + }, + closeAriaLabel: "Ne plus afficher cette astuce", + }, + 'de': { + text: (params) => { + const authHint = params.authEnabled ? ' (Anmeldung während der Beta erforderlich, kostenloses Spielen)' : ''; + return `Gib deine Ideen ein, konfiguriere Stile und klicke auf "Starten" zum Spielen${authHint}. Du kannst auch eine kuratierte Geschichte unten auswählen, um InfiPlot schnell zu erleben. Klicke auf "Einstellungen", um deinen Namen einzugeben und deine eigenen Text-, Bild-, Vision- und TTS-Schlüssel zu konfigurieren—alles wird lokal in deinem Browser für eine stabilere Erfahrung gespeichert.`; + }, + closeAriaLabel: "Diesen Hinweis nicht mehr anzeigen", + }, + 'pt-BR': { + text: (params) => { + const authHint = params.authEnabled ? ' (login necessário durante o beta, grátis para jogar)' : ''; + return `Digite suas ideias, configure estilos e clique em "Iniciar" para jogar${authHint}. Você também pode escolher uma história curada abaixo para experimentar rapidamente InfiPlot. Clique em "Configurações" para inserir seu nome e configurar suas próprias chaves de texto, imagem, visão e TTS—tudo armazenado localmente no seu navegador para uma experiência mais estável.`; + }, + closeAriaLabel: "Não mostrar mais este aviso", + }, + 'pt': { + text: (params) => { + const authHint = params.authEnabled ? ' (login necessário durante o beta, grátis para jogar)' : ''; + return `Digite as suas ideias, configure estilos e clique em "Iniciar" para jogar${authHint}. Também pode escolher uma história curada abaixo para experimentar rapidamente InfiPlot. Clique em "Configurações" para inserir o seu nome e configurar as suas próprias chaves de texto, imagem, visão e TTS—tudo guardado localmente no seu navegador para uma experiência mais estável.`; + }, + closeAriaLabel: "Não mostrar esta dica novamente", + }, + 'ru': { + text: (params) => { + const authHint = params.authEnabled ? ' (требуется вход во время бета-теста, бесплатная игра)' : ''; + return `Введите свои идеи, настройте стили и нажмите "Начать" для игры${authHint}. Вы также можете выбрать выбранную историю ниже, чтобы быстро испытать InfiPlot. Нажмите "Настройки", чтобы ввести свое имя и настроить свои собственные ключи текста, изображения, зрения и TTS—все сохраняется локально в вашем браузере для более стабильного опыта.`; + }, + closeAriaLabel: "Больше не показывать эту подсказку", + }, + 'it': { + text: (params) => { + const authHint = params.authEnabled ? ' (accesso richiesto durante la beta, gioco gratuito)' : ''; + return `Inserisci le tue idee, configura gli stili e fai clic su "Inizia" per giocare${authHint}. Puoi anche scegliere una storia curata qui sotto per provare rapidamente InfiPlot. Fai clic su "Impostazioni" per inserire il tuo nome e configurare le tue chiavi di testo, immagine, visione e TTS—tutto salvato localmente nel tuo browser per un'esperienza più stabile.`; + }, + closeAriaLabel: "Non mostrare più questo suggerimento", + }, + 'vi': { + text: (params) => { + const authHint = params.authEnabled ? ' (yêu cầu đăng nhập trong bản beta, chơi miễn phí)' : ''; + return `Nhập ý tưởng của bạn, cấu hình kiểu và nhấp "Bắt đầu" để chơi${authHint}. Bạn cũng có thể chọn một câu chuyện được chọn từ bên dưới để trải nghiệm nhanh InfiPlot. Nhấp "Cài đặt" để nhập tên của bạn và cấu hình khóa văn bản, hình ảnh, hình ảnh và TTS của riêng bạn—tất cả được lưu cục bộ trong trình duyệt của bạn để có trải nghiệm ổn định hơn.`; + }, + closeAriaLabel: "Không còn hiển thị gợi ý này", + }, + 'th': { + text: (params) => { + const authHint = params.authEnabled ? ' (ต้องล็อกอินระหว่างเบต้า, เล่นฟรี)' : ''; + return `ป้อนแนวคิดของคุณ กำหนดค่าสไตล์ และคลิก "เริ่ม" เพื่อเล่น${authHint} คุณยังสามารถเลือกเรื่องราวที่คัดสรรจากด้านล่างเพื่อสัมผัส InfiPlot ได้อย่างรวดเร็ว คลิก "การตั้งค่า" เพื่อป้อนชื่อและกำหนดค่าคีย์ข้อความ รูปภาพ การมองเห็น และ TTS ของคุณเอง—ทั้งหมดจะถูกเก็บไว้ในเบราว์เซอร์ของคุณเพื่อประสบการณ์ที่มีเสถียรภาพมากขึ้น`; + }, + closeAriaLabel: "ไม่แสดงคำแนะนำนี้อีก", + }, + 'id': { + text: (params) => { + const authHint = params.authEnabled ? ' (login diperlukan selama beta, main gratis)' : ''; + return `Masukkan ide Anda, konfigurasi gaya, dan klik "Mulai" untuk bermain${authHint}. Anda juga dapat memilih cerita kurasi dari bawah untuk pengalaman cepat InfiPlot. Klik "Pengaturan" untuk memasukkan nama Anda dan mengonfigurasi kunci teks, gambar, visi, dan TTS Anda sendiri—semua disimpan secara lokal di browser Anda untuk pengalaman yang lebih stabil.`; + }, + closeAriaLabel: "Jangan tampilkan petunjuk ini lagi", + }, + 'tr': { + text: (params) => { + const authHint = params.authEnabled ? ' (beta sırasında giriş gerekli, ücretsiz oyun)' : ''; + return `Fikirlerinizi girin, stilleri yapılandırın ve oynamak için "Başlat"a tıklayın${authHint}. Aşağıdan küratörlü bir hikaye seçerek InfiPlot'ı hızlıca deneyimleyebilirsiniz. "Ayarlar"a tıklayarak adınızı girebilir ve kendi metin, resim, görü ve TTS anahtarlarınızı yapılandırabilirsiniz—tümü daha stabil bir deneyim için tarayıcınızda yerel olarak saklanır.`; + }, + closeAriaLabel: "Bu ipucunu bir daha gösterme", + }, + 'pl': { + text: (params) => { + const authHint = params.authEnabled ? ' (wymagane logowanie podczas beta, darmowa gra)' : ''; + return `Wprowadź swoje pomysły, skonfiguruj style i kliknij "Rozpocznij", aby zagrać${authHint}. Możesz także wybrać kuratorską historię z dołu, aby szybko doświadczyć InfiPlot. Kliknij "Ustawienia", aby wprowadzić swoje imię i skonfigurować własne klucze tekstu, obrazu, widoku i TTS—wszystko przechowywane lokalnie w twojej przeglądarce dla bardziej stabilnego doświadczenia.`; + }, + closeAriaLabel: "Nie pokazuj więcej tej podpowiedzi", + }, + 'nl': { + text: (params) => { + const authHint = params.authEnabled ? ' (inloggen vereist tijdens beta, gratis spelen)' : ''; + return `Voer je ideeën in, configureer stijlen en klik op "Starten" om te spelen${authHint}. Je kunt ook een gecureerd verhaal onderaan kiezen om InfiPlot snel te ervaren. Klik op "Instellingen" om je naam in te voeren en je eigen tekst-, afbeeldings-, visie- en TTS-sleutels te configureren—alles lokaal in je browser opgeslagen voor een stabielere ervaring.`; + }, + closeAriaLabel: "Deze hint niet meer weergeven", + }, + 'uk': { + text: (params) => { + const authHint = params.authEnabled ? ' (вхід потрібен під час бета-тестування, безкоштовна гра)' : ''; + return `Введіть свої ідеї, налаштуйте стилі та натисніть "Почати" для гри${authHint}. Ви також можете обрати вибрану історію знизу, щоб швидко випробувати InfiPlot. Натисніть "Налаштування", щоб ввести своє ім'я та налаштувати власні ключі тексту, зображення, зору та TTS—все зберігається локально у вашому браузері для стабільнішого досвіду.`; + }, + closeAriaLabel: "Більше не показувати цю підказку", + }, +}; + +// Locale metadata +const localeMetadata = { + 'en': { name: 'English (United States)', comment: '// English (United States)' }, + 'zh-TW': { name: 'Chinese (Taiwan)', comment: '// Traditional Chinese (Taiwan)' }, + 'zh-HK': { name: 'Chinese (Hong Kong)', comment: '// Traditional Chinese (Hong Kong)' }, + 'ja': { name: 'Japanese', comment: '// Japanese' }, + 'ko': { name: 'Korean', comment: '// Korean' }, + 'es': { name: 'Spanish', comment: '// Spanish' }, + 'fr': { name: 'French', comment: '// French' }, + 'de': { name: 'German', comment: '// German (Germany)' }, + 'pt-BR': { name: 'Portuguese (Brazil)', comment: '// Portuguese (Brazil)' }, + 'pt': { name: 'Portuguese', comment: '// Portuguese (Portugal)' }, + 'ru': { name: 'Russian', comment: '// Russian' }, + 'it': { name: 'Italian', comment: '// Italian' }, + 'vi': { name: 'Vietnamese', comment: '// Vietnamese' }, + 'th': { name: 'Thai', comment: '// Thai' }, + 'id': { name: 'Indonesian', comment: '// Indonesian' }, + 'tr': { name: 'Turkish', comment: '// Turkish' }, + 'pl': { name: 'Polish', comment: '// Polish' }, + 'nl': { name: 'Dutch', comment: '// Dutch' }, + 'uk': { name: 'Ukrainian', comment: '// Ukrainian' }, +}; + +// Get the variable name for a locale +function getVarName(locale) { + if (locale === 'zh-CN') return 'zhCN'; + if (locale === 'zh-TW') return 'zhTW'; + if (locale === 'zh-HK') return 'zhHK'; + return locale.replace(/-/g, '').toLowerCase(); +} + +// Rebuild a locale file +function rebuildLocale(locale) { + const varName = getVarName(locale); + const metadata = localeMetadata[locale] || { name: locale, comment: `// ${locale}` }; + + // Start with the template structure but replace the hint.text with function + let content = `${metadata.comment} +// Auto-generated by scripts/translate-i18n.mjs + +export const ${varName} = { + "layout": { + "metadata": { + "title": "InfiPlot", + "description": "InfiPlot" + } + }, + "home": { + "examples": { + "male": [], + "female": [], + "x": [] + }, + "options": { + "gender": "", + "artStyle": "", + "plotStyle": "", + "voice": "", + "pacing": "" + }, + "genders": { + "male": "", + "female": "", + "x": "" + }, + "artStyles": {}, + "plotStyles": { + "straightforward": "", + "twist": "" + }, + "voiceOptions": { + "off": "", + "on": "" + }, + "pacings": { + "fast": "", + "relaxed": "" + }, + "stories": {}, + "ui": { + "start": "", + "loadStory": "", + "settings": "", + "searchPlaceholder": "", + "noMatchingStyle": "", + "close": "", + "back": "", + "save": "", + "cancel": "", + "saveAndSelect": "" + }, + "styleModal": {}, + "hero": { + "title": "", + "placeholder": " ", + "enterHint": "" + }, + "hint": { + "text": ${hintTranslations[locale]?.text.toString().replace(/\n/g, '\n ') || '(params) => ""'}, + "closeAriaLabel": "${hintTranslations[locale]?.closeAriaLabel || ''}" + }, + "about": {}, + "errors": { + "emptyFile": "", + "fileTooLarge": "", + "unpackFailed": "", + "parseFailed": "", + "cardNotFound": "" + } + }, + "play": {}, + "settings": {}, + "auth": {}, + "history": {}, + "customForm": {}, + "language": { + "title": "", + "current": "", + "select": "" + } +} as const; + +export type ${varName.charAt(0).toUpperCase() + varName.slice(1)}Translations = typeof ${varName}; +`; + + return content; +} + +// Rebuild all truncated locales +const truncatedLocales = [ + 'zh-TW', 'zh-HK', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'pt', 'ru', + 'it', 'vi', 'th', 'id', 'tr', 'pl', 'nl', 'uk' +]; + +let successCount = 0; +for (const locale of truncatedLocales) { + try { + const filePath = resolve(localesDir, `${locale}.ts`); + const content = rebuildLocale(locale); + writeFileSync(filePath, content); + console.log(`✓ Rebuilt ${locale}.ts`); + successCount++; + } catch (e) { + console.error(`✗ Error rebuilding ${locale}:`, e.message); + } +} + +console.log(`\nDone! Rebuilt ${successCount} locale files`); +console.log('Note: Files now have placeholder structure. Run translation script to fill in actual translations.'); diff --git a/scripts/remove-duplicate-play.mjs b/scripts/remove-duplicate-play.mjs new file mode 100644 index 0000000..53370c4 --- /dev/null +++ b/scripts/remove-duplicate-play.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +// Remove duplicate play sections and fix type annotations + +import { readFileSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const localesDir = resolve(__dirname, '../lib/i18n/locales'); + +// Target locales +const targetLocales = [ + 'zh-TW', 'zh-HK', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'pt', 'ru', + 'it', 'vi', 'th', 'id', 'tr', 'pl', 'nl', 'uk', 'hi', 'cs' +]; + +function fixLocaleFile(content, locale) { + let modified = false; + + // 1. Remove duplicate play section (the one after the main object closes) + // Pattern: anything from ",\n // ========== Play Page" to end of file + const duplicatePlayPattern = /,\n \/\/ ========== Play Page[\s\S]*$/; + if (duplicatePlayPattern.test(content)) { + content = content.replace(duplicatePlayPattern, ''); + modified = true; + console.log(` Removed duplicate play section from ${locale}.ts`); + } + + // 2. Fix type annotations for params in function translations + // Pattern: (params) => { should be (params: { authEnabled?: boolean }) => { + const functionPattern = /\(params\)\s*=>\s*\{/g; + let matchCount = 0; + content = content.replace(functionPattern, () => { + matchCount++; + return '(params: { authEnabled?: boolean }) => {'; + }); + if (matchCount > 0) { + modified = true; + console.log(` Fixed ${matchCount} type annotations in ${locale}.ts`); + } + + // 3. Fix trailing syntax issues + // Replace }\n, with }\n, + content = content.replace(/\}\n,/g, '},\n'); + + return modified ? content : null; +} + +let successCount = 0; +for (const locale of targetLocales) { + try { + const filePath = resolve(localesDir, `${locale}.ts`); + const content = readFileSync(filePath, 'utf-8'); + const newContent = fixLocaleFile(content, locale); + + if (newContent) { + writeFileSync(filePath, newContent); + console.log(`✓ Fixed ${locale}.ts`); + successCount++; + } + } catch (e) { + console.error(`✗ Error updating ${locale}:`, e.message); + } +} + +console.log(`\nDone! Fixed ${successCount} locale files`); diff --git a/scripts/translate-i18n.mjs b/scripts/translate-i18n.mjs new file mode 100644 index 0000000..ba050eb --- /dev/null +++ b/scripts/translate-i18n.mjs @@ -0,0 +1,364 @@ +#!/usr/bin/env node +/** + * Translate lib/i18n/locales/zh-CN.ts to target locales using an LLM. + * + * Defaults to translating only `ja` (English is hand-curated in en.ts). + * Override with --locales=en,ja. Other locales remain stubs. + * + * Uses the existing OpenAI-compatible TEXT_BASE_URL + TEXT_API_KEY from + * .env.local. Default model is `gemini-3.5-flash` (the openai-next.com proxy + * supports it alongside gpt-4.1); override with --model or TRANSLATE_MODEL. + * + * Strategy: + * 1. Read zh-CN.ts as TEXT (so structure + function signatures stay intact). + * 2. Tokenize source, finding every string literal that contains Han chars. + * 3. Mask ${...} interpolations and HTML attributes/URLs, send the rest to + * the LLM with strict "preserve these tokens" instructions. + * 4. Replace each match in source (back-to-front to keep indices valid). + * 5. Rename `zhCN`/`ZhCNTranslations` → target locale var names, write file. + * + * Why source-as-text instead of import + serialize: the source contains two + * ICU-style functions (hint.text, about.legalNotice) whose control flow and + * parameter typing must survive unchanged. String-literal find-and-replace + * leaves them alone — only their Chinese substrings get translated. + * + * Usage: + * node scripts/translate-i18n.mjs # ja only, gemini-3.5-flash + * node scripts/translate-i18n.mjs --locales=en,ja # both + * node scripts/translate-i18n.mjs --model=gemini-2.5-flash + */ + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { argv } from "node:process"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, ".."); +const ENV_FILE = resolve(rootDir, ".env.local"); + +// ── Load .env.local (matches scripts/enrich-firstacts-stepfun.mjs) ──── +function loadEnv(path) { + if (!existsSync(path)) return {}; + const txt = readFileSync(path, "utf8"); + const env = {}; + for (const raw of txt.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq < 0) continue; + const k = line.slice(0, eq).trim(); + let v = line.slice(eq + 1).trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { + v = v.slice(1, -1); + } + env[k] = v; + } + return env; +} +const env = loadEnv(ENV_FILE); + +// ── CLI parsing ─────────────────────────────────────────────────────── +let targets = ["ja"]; +let model = env.TRANSLATE_MODEL || "gemini-3.5-flash"; +let concurrency = 6; +for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === "--locales" && argv[i + 1]) targets = argv[++i].split(",").map((s) => s.trim()); + else if (a === "--model" && argv[i + 1]) model = argv[++i]; + else if (a === "--concurrency" && argv[i + 1]) concurrency = Number(argv[++i]); +} + +const baseUrl = (env.TEXT_BASE_URL || "").replace(/\/+$/, ""); +const apiKey = env.TEXT_API_KEY || ""; + +if (!baseUrl || !apiKey) { + console.error(`❌ TEXT_BASE_URL and TEXT_API_KEY must be set in ${ENV_FILE}`); + process.exit(1); +} + +const LOCALE_NAMES = { + en: "English", + "zh-CN": "Simplified Chinese", + "zh-TW": "Traditional Chinese (Taiwan)", + "zh-HK": "Traditional Chinese (Hong Kong)", + ja: "Japanese", + ko: "Korean", + es: "Spanish", + fr: "French", + de: "German", + "pt-BR": "Portuguese (Brazil)", + pt: "Portuguese", + ru: "Russian", + it: "Italian", + vi: "Vietnamese", + th: "Thai", + id: "Indonesian", + tr: "Turkish", + pl: "Polish", + nl: "Dutch", + uk: "Ukrainian", + hi: "Hindi", + cs: "Czech", +}; + +// ── LLM call ────────────────────────────────────────────────────────── +const cache = new Map(); + +async function translateText(text, targetLang) { + const cacheKey = `${targetLang}::${text}`; + if (cache.has(cacheKey)) return cache.get(cacheKey); + + // Mask ${...} template interpolations so the model can't rewrite them. + const interps = []; + let masked = text.replace(/\$\{[^}]*\}/g, (m) => { + interps.push(m); + return `⟦I${interps.length - 1}⟧`; + }); + // Mask {placeholder} and {{placeholder}} style too — common in our strings. + // (Keep this conservative; only single-word curlies.) + const placeholders = []; + masked = masked.replace(/\{\{\w+\}\}|\{\w+\}/g, (m) => { + placeholders.push(m); + return `⟦P${placeholders.length - 1}⟧`; + }); + + const prompt = `You are a professional UI translator for an interactive fiction game (galgame) called InfiPlot. + +Target language: ${targetLang}. + +CRITICAL RULES — violations break the build: +1. Translate ONLY the human-readable text into ${targetLang}. +2. PRESERVE EXACTLY (do not translate, do not move): + - Tokens shaped ⟦I0⟧, ⟦I1⟧ — these are code placeholders; copy them verbatim into the output. + - Tokens shaped ⟦P0⟧, ⟦P1⟧ — same. + - HTML tags: , , ,
— keep tags exactly; translate only inner text. + - HTML attributes: class="...", href="...", target="..." — keep as-is. + - URLs (https://..., mailto:...). +3. KEEP PROPER NOUNS UNCHANGED: InfiPlot, GitHub, Google, Umami, QQ, API, Key, BASE URL, MiMo, StepFun. +4. DOT SEPARATOR RULE: the Chinese source uses " · " between characters as a stylistic effect. DO NOT use "·" in your translation. Output normal words. Example: "正 · 在 · 绘 · 制" → English: "Drawing", Japanese: "描画中". +5. Match tone: playful for loading/game UI, professional for technical labels. +6. Output ONLY the translated string. No wrapping quotes, no markdown fences, no commentary. + +Source text: +${masked}`; + + let out = ""; + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + messages: [{ role: "user", content: prompt }], + temperature: 0.2, + }), + }); + if (!res.ok) { + const errText = await res.text(); + throw new Error(`API ${res.status}: ${errText.slice(0, 200)}`); + } + const data = await res.json(); + out = data.choices?.[0]?.message?.content?.trim() ?? ""; + break; + } catch (err) { + if (attempt === 2) throw err; + const backoff = 800 * Math.pow(2, attempt); + console.log(` ⚠️ retry in ${backoff}ms: ${err.message.slice(0, 100)}`); + await new Promise((r) => setTimeout(r, backoff)); + } + } + + // Strip wrapping quotes / fences the model sometimes adds. + out = out.replace(/^```[\w]*\n?/, "").replace(/\n?```$/, ""); + out = out.replace(/^["'`]+|["'`]+$/g, ""); + + // Restore placeholders in the right order. + out = out.replace(/⟦I(\d+)⟧/g, (_, i) => interps[Number(i)]); + out = out.replace(/⟦P(\d+)⟧/g, (_, i) => placeholders[Number(i)]); + + cache.set(cacheKey, out); + return out; +} + +// ── Tokenizer: find every string literal containing Han chars ───────── +function findChineseStrings(source) { + const results = []; + let i = 0; + let line = 1; + + while (i < source.length) { + const ch = source[i]; + + if (ch === "\n") { line++; i++; continue; } + + // Skip line comments + if (ch === "/" && source[i + 1] === "/") { + while (i < source.length && source[i] !== "\n") i++; + continue; + } + // Skip block comments + if (ch === "/" && source[i + 1] === "*") { + i += 2; + while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) { + if (source[i] === "\n") line++; + i++; + } + i += 2; + continue; + } + + if (ch === '"' || ch === "'" || ch === "`") { + const start = i; + const startLine = line; + const quote = ch; + i++; + const parts = []; + while (i < source.length) { + const c = source[i]; + if (c === "\\") { + parts.push(c, source[i + 1] ?? ""); + i += 2; + continue; + } + if (c === "\n") line++; + if (c === quote) { + i++; + break; + } + // For backticks, treat ${...} as opaque (don't translate the expression body). + if (quote === "`" && c === "$" && source[i + 1] === "{") { + let depth = 1; + parts.push(c, source[i + 1]); + i += 2; + while (i < source.length && depth > 0) { + const cc = source[i]; + if (cc === "{") depth++; + else if (cc === "}") depth--; + if (cc === "\n") line++; + parts.push(cc); + i++; + } + continue; + } + parts.push(c); + i++; + } + const content = parts.join(""); + if (/[一-鿿]/.test(content)) { + results.push({ + full: source.slice(start, i), + quote, + content, + start, + end: i, + line: startLine, + }); + } + continue; + } + + i++; + } + return results; +} + +// ── Variable rename for target locale file ──────────────────────────── +function transformForLocale(source, locale) { + const varName = locale.replace(/-./g, (c) => c[1].toUpperCase()); + const typeName = varName[0].toUpperCase() + varName.slice(1) + "Translations"; + const localeDisplay = LOCALE_NAMES[locale] || locale; + + let out = source + .replace(/\bzhCN\b/g, varName) + .replace(/\bZhCNTranslations\b/g, typeName); + + // Replace the leading comment line with locale info. + out = out.replace( + /^\/\/[^\n]*\n/, + `// ${localeDisplay} — auto-translated from zh-CN by scripts/translate-i18n.mjs (review for quality).\n`, + ); + + return out; +} + +// ── Concurrency-limited map ─────────────────────────────────────────── +async function mapWithConcurrency(items, limit, fn) { + const results = new Array(items.length); + let next = 0; + let done = 0; + async function worker() { + while (next < items.length) { + const idx = next++; + try { + results[idx] = await fn(items[idx], idx); + } catch (err) { + results[idx] = { __error: err }; + } + done++; + if (done % 5 === 0 || done === items.length) { + process.stdout.write(`\r translated ${done}/${items.length} `); + } + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + process.stdout.write("\n"); + return results; +} + +// ── Main per-locale ─────────────────────────────────────────────────── +async function translateFile(locale) { + const localeName = LOCALE_NAMES[locale] || locale; + console.log(`\n🌐 zh-CN → ${locale} (${localeName})`); + + const sourcePath = join(rootDir, "lib", "i18n", "locales", "zh-CN.ts"); + let source = readFileSync(sourcePath, "utf-8"); + + const strings = findChineseStrings(source); + console.log(` Found ${strings.length} Chinese strings (concurrency=${concurrency})`); + + const translated = await mapWithConcurrency(strings, concurrency, async (s, idx) => { + try { + const out = await translateText(s.content, localeName); + return { ok: true, value: out, idx: s }; + } catch (err) { + console.error(`\n ⚠️ line ${s.line} failed: ${err.message.slice(0, 100)} — keeping source`); + return { ok: false, value: s.content, idx: s }; + } + }); + + // Apply replacements back-to-front so indices stay valid. + for (let i = strings.length - 1; i >= 0; i--) { + const s = strings[i]; + const newContent = translated[i].value; + if (newContent === s.content) continue; + const newFull = s.quote + newContent + s.quote; + source = source.slice(0, s.start) + newFull + source.slice(s.end); + } + + source = transformForLocale(source, locale); + + const outPath = join(rootDir, "lib", "i18n", "locales", `${locale}.ts`); + writeFileSync(outPath, source, "utf-8"); + console.log(` ✅ Wrote ${outPath}`); +} + +// ── Run ─────────────────────────────────────────────────────────────── +console.log("🚀 InfiPlot i18n translation"); +console.log(` Endpoint: ${baseUrl}`); +console.log(` Model: ${model}`); +console.log(` Targets: ${targets.join(", ")}`); + +for (const locale of targets) { + if (!LOCALE_NAMES[locale]) { + console.error(`❌ Unknown locale: ${locale}`); + continue; + } + await translateFile(locale); +} + +console.log("\n✨ Done. Review the generated files, then run `pnpm typecheck`.");