fix(i18n): overhaul i18n with [locale] routing, SSR translations, and hreflang SEO
Rewrites the i18n system introduced in PR #94 to use Next.js App Router [locale] dynamic segments with SSR-rendered translations and proper middleware locale routing. - Add middleware locale detection: / rewrites to /zh-CN/ internally, /en and /ja pass through, /zh-CN/... redirects to bare path - Move all 7 pages under app/[locale]/ with SSR translation injection - Fix server→client serialization: pre-evaluate function-valued translations (makeSerializable) to eliminate hydration flash - Fix language switch key flash: use hard navigation with localStorage- only persistence, avoiding React state update before page reload - Add <link rel="alternate" hreflang> tags for multilingual SEO - Fix Supabase setAll overwriting locale rewrite response - Trim locales from 22 to 3 (zh-CN/en/ja), delete 19 incomplete files - LLM-translate 240 firstact game preset JSONs (en + ja, landscape + portrait) and story titles via gemini-3.5-flash - Delete 11 one-off migration scripts and outdated i18n docs - Add useLocalePath hook and navigation utilities Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
downloadImagesAsZip,
|
||||
inferImageExtension,
|
||||
} from "@/lib/imageZipDownload";
|
||||
import { useLocalePath } from "@/lib/i18n/hooks";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Gallery — an offline-only replay of a played session. Entered from
|
||||
@@ -608,6 +609,7 @@ type Frame = {
|
||||
};
|
||||
|
||||
function GalleryInner() {
|
||||
const lp = useLocalePath();
|
||||
const [doc, setDoc] = useState<GalleryDoc | null>(null);
|
||||
const [missingId, setMissingId] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
@@ -1068,7 +1070,7 @@ function GalleryInner() {
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
href={lp("/")}
|
||||
className="mt-6 text-[10px] smallcaps text-clay-700 hover:text-ember-500 transition-colors inline-flex items-center gap-3"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left text-[9px]" />
|
||||
@@ -1128,7 +1130,7 @@ function GalleryInner() {
|
||||
style={{ paddingTop: "max(0.75rem, env(safe-area-inset-top))" }}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
href={lp("/")}
|
||||
className="pointer-events-auto flex h-9 items-center gap-2 rounded-full bg-black/40 px-3 text-[11px] smallcaps text-white/80 backdrop-blur-sm transition-colors hover:text-white"
|
||||
aria-label="返回"
|
||||
>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { LOCALES, type Locale } from "@/lib/i18n/config";
|
||||
import { loadTranslations } from "@/lib/i18n/server";
|
||||
import { I18nProvider } from "@/lib/i18n/client";
|
||||
import { isValidLocale } from "@/lib/i18n/utils";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
if (!isValidLocale(locale)) notFound();
|
||||
|
||||
const translations = await loadTranslations(locale as Locale);
|
||||
|
||||
return (
|
||||
<I18nProvider initialLocale={locale as Locale} initialTranslations={translations}>
|
||||
{children}
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
import Link from "next/link";
|
||||
import { CustomForm } from "@/components/CustomForm";
|
||||
import { localePath } from "@/lib/i18n/navigation";
|
||||
import { isValidLocale } from "@/lib/i18n/utils";
|
||||
import { DEFAULT_LOCALE, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
export default async function NewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale: rawLocale } = await params;
|
||||
const locale: Locale = isValidLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
|
||||
const lp = (path: string) => localePath(path, locale);
|
||||
|
||||
export default function NewPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="px-6 md:px-16 pt-7 md:pt-10 flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
href={lp("/")}
|
||||
className="text-[10px] smallcaps text-clay-700 hover:text-clay-900 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left text-[9px]" />
|
||||
@@ -23,6 +23,7 @@ import { AuthModal } from "@/components/AuthModal";
|
||||
import { UserChip } from "@/components/UserChip";
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { useI18n } from "@/lib/i18n/client";
|
||||
import { useLocalePath } from "@/lib/i18n/hooks";
|
||||
|
||||
// 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
|
||||
@@ -813,6 +814,33 @@ function buildFallbackCards(g: Gender): FeaturedCard[] {
|
||||
});
|
||||
}
|
||||
|
||||
type StoriesI18n = { male: StoryContent[]; female: StoryContent[] };
|
||||
|
||||
async function loadStoriesI18n(locale: string): Promise<StoriesI18n | null> {
|
||||
if (locale === "zh-CN") return null;
|
||||
try {
|
||||
const mod = locale === "en"
|
||||
? await import("@/lib/i18n/stories-en.json")
|
||||
: locale === "ja"
|
||||
? await import("@/lib/i18n/stories-ja.json")
|
||||
: null;
|
||||
return mod ? (mod.default as StoriesI18n) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function localizeCards(cards: FeaturedCard[], i18n: StoriesI18n | null): FeaturedCard[] {
|
||||
if (!i18n) return cards;
|
||||
return cards.map((card) => {
|
||||
const m = card.id.match(/^([mf])(\d+)$/);
|
||||
if (!m) return card;
|
||||
const gender = m[1] === "f" ? "female" : "male";
|
||||
const idx = parseInt(m[2]!, 10);
|
||||
const translated = i18n[gender]?.[idx];
|
||||
if (!translated) return card;
|
||||
return { ...card, title: translated.title, outline: translated.outline };
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- typewriter ---------- */
|
||||
|
||||
// 父组件持有当前 phrase 的索引(这样 start() 不输入时能用当前闪动的那句
|
||||
@@ -1418,6 +1446,7 @@ function StyleModal({
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
const { t, locale, tArray } = useI18n();
|
||||
const lp = useLocalePath();
|
||||
|
||||
const [sel, setSel] = useState<number[]>(OPTS.map((o) => o.defaultIndex ?? 0));
|
||||
const [open, setOpen] = useState<number>(-1);
|
||||
@@ -1483,7 +1512,6 @@ export default function HomePage() {
|
||||
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);
|
||||
@@ -1506,36 +1534,49 @@ export default function HomePage() {
|
||||
|
||||
// Featured stories 动态加载(从 /api/stories/featured),降级用硬编码 STORIES。
|
||||
// 惰性初始化确保首屏即有卡片内容(SSR + hydration 一致),fetch 成功后无缝替换。
|
||||
const storiesI18nRef = useRef<{ locale: string; data: StoriesI18n | null }>({ locale: "", data: null });
|
||||
const [featuredCards, setFeaturedCards] = useState<FeaturedCard[]>(() =>
|
||||
buildFallbackCards(galleryGender),
|
||||
);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
if (storiesI18nRef.current.locale !== locale) {
|
||||
storiesI18nRef.current = { locale, data: await loadStoriesI18n(locale) };
|
||||
}
|
||||
const i18n = storiesI18nRef.current.data;
|
||||
if (cancelled) return;
|
||||
|
||||
const apiGender = galleryGender === "女性向" ? "female" : "male";
|
||||
fetch(`/api/stories/featured?gender=${apiGender}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { stories: FeaturedStoryRow[] }) => {
|
||||
try {
|
||||
const r = await fetch(`/api/stories/featured?gender=${apiGender}`);
|
||||
const data: { stories: FeaturedStoryRow[] } = await r.json();
|
||||
// API 已按 sortOrder 排序且仅返回 isActive=1 的记录。
|
||||
// D1 故障时 featured route 返回 { stories: [] }(HTTP 200),
|
||||
// 空数组也必须降级到常量,否则首页白屏。
|
||||
const rows = data.stories ?? [];
|
||||
if (cancelled) return;
|
||||
if (rows.length === 0) {
|
||||
setFeaturedCards(buildFallbackCards(galleryGender));
|
||||
setFeaturedCards(localizeCards(buildFallbackCards(galleryGender), i18n));
|
||||
return;
|
||||
}
|
||||
setFeaturedCards(
|
||||
localizeCards(
|
||||
rows.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
outline: s.outline,
|
||||
coverPath: s.coverPath,
|
||||
})),
|
||||
i18n,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// 网络故障 / JSON 解析失败 → 降级到常量
|
||||
setFeaturedCards(buildFallbackCards(galleryGender));
|
||||
});
|
||||
}, [galleryGender]);
|
||||
} catch {
|
||||
if (!cancelled) setFeaturedCards(localizeCards(buildFallbackCards(galleryGender), i18n));
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [galleryGender, locale]);
|
||||
|
||||
/* close any open dropdown on outside click */
|
||||
useEffect(() => {
|
||||
@@ -1755,7 +1796,7 @@ export default function HomePage() {
|
||||
"infiplot:custom",
|
||||
JSON.stringify({ worldSetting, styleGuide, audioEnabled, styleReferenceImage, playerName: playerName || undefined }),
|
||||
);
|
||||
router.push("/play?custom=1");
|
||||
router.push(lp("/play?custom=1"));
|
||||
};
|
||||
|
||||
const handleStoryImport = async (file: File | undefined) => {
|
||||
@@ -1790,7 +1831,7 @@ export default function HomePage() {
|
||||
}
|
||||
const doc = parseStoryShareDoc(JSON.parse(text));
|
||||
window.sessionStorage.setItem(STORY_SHARE_STORAGE_KEY, JSON.stringify(doc));
|
||||
router.push("/play?share=1");
|
||||
router.push(lp("/play?share=1"));
|
||||
} catch (e) {
|
||||
setStoryImportError(e instanceof Error ? e.message : t("home.errors.parseFailed"));
|
||||
} finally {
|
||||
@@ -1823,7 +1864,7 @@ export default function HomePage() {
|
||||
tts: audioEnabled,
|
||||
card: cardId as `${"m" | "f"}${number}`,
|
||||
});
|
||||
router.push(`/play?card=${cardId}`);
|
||||
router.push(lp(`/play?card=${cardId}`));
|
||||
};
|
||||
|
||||
// overflow-x-hidden 在 wrapper 层兜底:body 的 overflow-x-hidden 在移动端会因
|
||||
@@ -2107,9 +2148,9 @@ export default function HomePage() {
|
||||
<div className="flex flex-col items-center gap-2 text-[10px] smallcaps text-clay-500">
|
||||
<span>{t("home.about.copyright")}</span>
|
||||
<span className="flex items-center gap-3 normal-case tracking-normal text-[11px]">
|
||||
<a href="/privacy" className="hover:text-ember-500 transition-colors">{t("home.about.privacyPolicy")}</a>
|
||||
<a href={lp("/privacy")} className="hover:text-ember-500 transition-colors">{t("home.about.privacyPolicy")}</a>
|
||||
<span className="text-clay-300">·</span>
|
||||
<a href="/terms" className="hover:text-ember-500 transition-colors">{t("home.about.terms")}</a>
|
||||
<a href={lp("/terms")} className="hover:text-ember-500 transition-colors">{t("home.about.terms")}</a>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type Phase,
|
||||
} from "@/components/PlayCanvas";
|
||||
import type { DialogueHistoryItem } from "@/components/DialogueHistoryModal";
|
||||
import type { GalleryDoc, GalleryScene } from "@/app/gallery/page";
|
||||
import type { GalleryDoc, GalleryScene } from "@/app/[locale]/gallery/page";
|
||||
import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal";
|
||||
import { annotateClick } from "@/lib/annotateClient";
|
||||
import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
|
||||
@@ -59,6 +59,7 @@ import { writeResumeSnapshot, consumeResumeSnapshot } from "@/lib/authResume";
|
||||
import { AuthModal } from "@/components/AuthModal";
|
||||
import { UserChip } from "@/components/UserChip";
|
||||
import { useI18n } from "@/lib/i18n/client";
|
||||
import { useLocalePath } from "@/lib/i18n/hooks";
|
||||
|
||||
const MUTED_STORAGE_KEY = "infiplot:muted";
|
||||
// One-shot snapshot of in-progress game state, written just before an OAuth
|
||||
@@ -605,6 +606,7 @@ function PlayInner() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const { t, locale } = useI18n();
|
||||
const lp = useLocalePath();
|
||||
|
||||
const [phase, setPhase] = useState<Phase>("loading-first");
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
@@ -1703,7 +1705,7 @@ function PlayInner() {
|
||||
const sessionLanguage: string = locale;
|
||||
|
||||
if (!cardName && !livePayload && !storyId) {
|
||||
router.replace("/");
|
||||
router.replace(lp("/"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1712,12 +1714,12 @@ function PlayInner() {
|
||||
// TEMPORARY: localStorage-only mode (D1 disabled until auth integration)
|
||||
const loadedSession = loadFromLocalStorage(storyId);
|
||||
if (!loadedSession) {
|
||||
setError("找不到保存的剧情");
|
||||
setError(t("play.savedStoryNotFound"));
|
||||
return;
|
||||
}
|
||||
const firstScene = loadedSession.history[0]?.scene;
|
||||
if (!firstScene) {
|
||||
setError("剧情数据损坏");
|
||||
setError(t("play.savedStoryCorrupted"));
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
@@ -1752,22 +1754,37 @@ function PlayInner() {
|
||||
cardGender?: string;
|
||||
};
|
||||
|
||||
const firstactDir = sessionOrientation === "portrait"
|
||||
const baseDir = sessionOrientation === "portrait"
|
||||
? "firstact-portrait"
|
||||
: "firstact";
|
||||
const localeSuffix = locale !== "zh-CN" ? `-${locale}` : "";
|
||||
const firstactDir = `${baseDir}${localeSuffix}`;
|
||||
|
||||
const startT0 = Date.now();
|
||||
const fetchStart: Promise<PrebakedFirstAct> = cardName
|
||||
? fetch(`/home/${firstactDir}/${encodeURIComponent(cardName)}.json`).then(
|
||||
async (r) => {
|
||||
if (r.ok) return (await r.json()) as PrebakedFirstAct;
|
||||
// Fallback chain: locale-specific → zh-CN portrait → zh-CN landscape
|
||||
if (localeSuffix) {
|
||||
const zhFb = await fetch(`/home/${baseDir}/${encodeURIComponent(cardName)}.json`);
|
||||
if (zhFb.ok) return (await zhFb.json()) as PrebakedFirstAct;
|
||||
}
|
||||
if (sessionOrientation === "portrait") {
|
||||
console.warn(`[play] portrait firstact missing for ${cardName} (HTTP ${r.status}), falling back to landscape`);
|
||||
const fb = await fetch(`/home/firstact/${encodeURIComponent(cardName)}.json`);
|
||||
const fbDir = localeSuffix ? `firstact-${locale}` : "firstact";
|
||||
const fb = await fetch(`/home/${fbDir}/${encodeURIComponent(cardName)}.json`);
|
||||
if (fb.ok) {
|
||||
const fallback = (await fb.json()) as PrebakedFirstAct;
|
||||
return { ...fallback, scene: { ...fallback.scene, orientation: "landscape" as const } };
|
||||
}
|
||||
if (localeSuffix) {
|
||||
const zhLandscape = await fetch(`/home/firstact/${encodeURIComponent(cardName)}.json`);
|
||||
if (zhLandscape.ok) {
|
||||
const fallback = (await zhLandscape.json()) as PrebakedFirstAct;
|
||||
return { ...fallback, scene: { ...fallback.scene, orientation: "landscape" as const } };
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(t("home.errors.cardNotFound", { cardName }));
|
||||
},
|
||||
@@ -2465,7 +2482,7 @@ function PlayInner() {
|
||||
{error}
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
href={lp("/")}
|
||||
className="mt-4 text-[10px] smallcaps text-clay-700 hover:text-ember-500 transition-colors inline-flex items-center gap-3"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left text-[9px]" />
|
||||
@@ -2512,7 +2529,7 @@ function PlayInner() {
|
||||
style={{ paddingTop: "max(0.5rem, env(safe-area-inset-top))" }}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
href={lp("/")}
|
||||
className="pointer-events-auto flex h-9 w-9 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-sm transition-colors hover:text-white"
|
||||
aria-label={t("play.tooltips.back")}
|
||||
>
|
||||
@@ -2590,7 +2607,7 @@ function PlayInner() {
|
||||
)}
|
||||
<header className="px-5 md:px-12 pt-6 md:pt-8 flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
href={lp("/")}
|
||||
className="text-clay-600 hover:text-clay-900 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left text-[12px]" />
|
||||
@@ -1,16 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { localePath } from "@/lib/i18n/navigation";
|
||||
import { isValidLocale } from "@/lib/i18n/utils";
|
||||
import { DEFAULT_LOCALE, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "隐私政策 — InfiPlot",
|
||||
description: "InfiPlot 隐私政策:了解我们如何收集、使用和保护您的个人信息。",
|
||||
};
|
||||
|
||||
export default function PrivacyPage() {
|
||||
export default async function PrivacyPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale: rawLocale } = await params;
|
||||
const locale: Locale = isValidLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
|
||||
const lp = (path: string) => localePath(path, locale);
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-3xl px-6 md:px-16 py-16 md:py-24">
|
||||
<Link
|
||||
href="/"
|
||||
href={lp("/")}
|
||||
className="inline-flex items-center gap-2 text-clay-500 hover:text-ember-500 transition-colors text-sm mb-12"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left text-xs" />
|
||||
@@ -4,8 +4,10 @@ import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { loadStoryList, deleteStory } from "@/lib/clientStoryPersistence";
|
||||
import type { StoryMeta } from "@/lib/db/repositories/storyRepo";
|
||||
import { useLocalePath } from "@/lib/i18n/hooks";
|
||||
|
||||
export default function StoriesPage() {
|
||||
const lp = useLocalePath();
|
||||
const [stories, setStories] = useState<StoryMeta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
@@ -58,7 +60,7 @@ export default function StoriesPage() {
|
||||
{/* ================== HEADER ================== */}
|
||||
<header className="px-6 md:px-16 pt-7 md:pt-10 flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
href={lp("/")}
|
||||
className="text-[10px] smallcaps text-clay-700 hover:text-clay-900 transition-colors flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left text-[9px]" />
|
||||
@@ -84,7 +86,7 @@ export default function StoriesPage() {
|
||||
还没有保存的剧情
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
href={lp("/")}
|
||||
className="text-[10px] smallcaps text-clay-700 hover:text-ember-500 transition-colors cursor-pointer"
|
||||
>
|
||||
回到首页开始新的故事
|
||||
@@ -99,7 +101,7 @@ export default function StoriesPage() {
|
||||
className="bg-cream-100 border border-clay-900/10 rounded-sm p-6 transition-all duration-200 hover:shadow-md hover:border-clay-900/20 relative group"
|
||||
>
|
||||
<Link
|
||||
href={`/play?storyId=${encodeURIComponent(story.id)}`}
|
||||
href={lp(`/play?storyId=${encodeURIComponent(story.id)}`)}
|
||||
className="block cursor-pointer"
|
||||
>
|
||||
<div className="mb-4">
|
||||
@@ -1,16 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { localePath } from "@/lib/i18n/navigation";
|
||||
import { isValidLocale } from "@/lib/i18n/utils";
|
||||
import { DEFAULT_LOCALE, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "服务条款 — InfiPlot",
|
||||
description: "InfiPlot 服务条款:使用 InfiPlot 服务前请阅读本条款。",
|
||||
};
|
||||
|
||||
export default function TermsPage() {
|
||||
export default async function TermsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale: rawLocale } = await params;
|
||||
const locale: Locale = isValidLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
|
||||
const lp = (path: string) => localePath(path, locale);
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-3xl px-6 md:px-16 py-16 md:py-24">
|
||||
<Link
|
||||
href="/"
|
||||
href={lp("/")}
|
||||
className="inline-flex items-center gap-2 text-clay-500 hover:text-ember-500 transition-colors text-sm mb-12"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left text-xs" />
|
||||
@@ -124,7 +135,7 @@ export default function TermsPage() {
|
||||
<p>
|
||||
公测期间生成的游戏内容不会被持久保存在我们的服务器上。为提供 AI 生成服务,相关内容会在请求处理过程中临时传输和处理,处理完成后不会被保留。有关我们如何处理您的个人信息,请参阅我们的{" "}
|
||||
<Link
|
||||
href="/privacy"
|
||||
href={lp("/privacy")}
|
||||
className="text-ember-500 hover:text-ember-400 transition-colors underline decoration-clay-900/20 underline-offset-2"
|
||||
>
|
||||
隐私政策
|
||||
+27
-4
@@ -1,7 +1,10 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { Cormorant_Garamond, Inter } from "next/font/google";
|
||||
import { Analytics } from "@/components/Analytics";
|
||||
import { I18nProvider } from "@/lib/i18n/client";
|
||||
import { LOCALES, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/config";
|
||||
import { localePath } from "@/lib/i18n/navigation";
|
||||
import { stripLocalePrefix } from "@/lib/i18n/navigation";
|
||||
import "./globals.css";
|
||||
|
||||
// Editorial fonts: drive tailwind `font-serif`/`font-sans` via
|
||||
@@ -35,14 +38,25 @@ export const viewport: Viewport = {
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const headersList = await headers();
|
||||
const locale = headersList.get("x-locale") || "zh-CN";
|
||||
|
||||
const origin =
|
||||
process.env.NEXT_PUBLIC_BASE_URL
|
||||
|| (process.env.VERCEL_PROJECT_PRODUCTION_URL
|
||||
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
|
||||
: "https://infiplot.com");
|
||||
const pathname = headersList.get("x-pathname") || "/";
|
||||
const barePath = stripLocalePrefix(pathname);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
lang={locale}
|
||||
className={`${cormorant.variable} ${inter.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
@@ -52,9 +66,18 @@ export default function RootLayout({
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
/>
|
||||
{LOCALES.map((l) => (
|
||||
<link
|
||||
key={l}
|
||||
rel="alternate"
|
||||
hrefLang={l}
|
||||
href={`${origin}${localePath(barePath, l)}`}
|
||||
/>
|
||||
))}
|
||||
<link rel="alternate" hrefLang="x-default" href={`${origin}${barePath}`} />
|
||||
</head>
|
||||
<body className="bg-cream-50 text-clay-900 font-sans antialiased min-h-screen overflow-x-hidden">
|
||||
<I18nProvider>{children}</I18nProvider>
|
||||
{children}
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,10 +4,12 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { useI18n } from "@/lib/i18n/client";
|
||||
import { useLocalePath } from "@/lib/i18n/hooks";
|
||||
|
||||
export function CustomForm() {
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const lp = useLocalePath();
|
||||
const [worldSetting, setWorldSetting] = useState("");
|
||||
const [styleGuide, setStyleGuide] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -26,7 +28,7 @@ export function CustomForm() {
|
||||
JSON.stringify({ worldSetting, styleGuide }),
|
||||
);
|
||||
track("game_start", { source: "custom" });
|
||||
router.push("/play?custom=1");
|
||||
router.push(lp("/play?custom=1"));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useI18n } from "@/lib/i18n/client";
|
||||
import { LOCALES, LOCALE_NAMES, type Locale } from "@/lib/i18n/config";
|
||||
import { LOCALES, LOCALE_NAMES, type Locale, setLocale as saveLocalePreference } from "@/lib/i18n/config";
|
||||
import { localePath, stripLocalePrefix } from "@/lib/i18n/navigation";
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
className?: string;
|
||||
@@ -11,45 +13,29 @@ interface LanguageSwitcherProps {
|
||||
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<Locale, string> = {
|
||||
"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 { locale, t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
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));
|
||||
|
||||
function switchTo(newLocale: Locale) {
|
||||
const basePath = stripLocalePrefix(pathname);
|
||||
const newPath = localePath(basePath, newLocale);
|
||||
// Only persist to localStorage — do NOT update React state (setLocale)
|
||||
// because that triggers a re-render with isLoading=true before the
|
||||
// browser navigates away, flashing translation keys for one frame.
|
||||
saveLocalePreference(newLocale);
|
||||
window.location.href = newPath;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
@@ -85,14 +71,11 @@ export function LanguageSwitcher({ className = "", variant = "full" }: LanguageS
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 w-44 overflow-hidden rounded-sm border border-clay-900/15 bg-cream-50 shadow-xl shadow-clay-900/10 z-20">
|
||||
<div className="py-1">
|
||||
{availableLocales.map((loc) => (
|
||||
{LOCALES.map((loc) => (
|
||||
<button
|
||||
key={loc}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLocale(loc);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
onClick={() => switchTo(loc)}
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-sm font-serif transition-colors hover:bg-cream-100 ${
|
||||
locale === loc ? "text-ember-500" : "text-clay-700"
|
||||
}`}
|
||||
@@ -108,4 +91,3 @@ export function LanguageSwitcher({ className = "", variant = "full" }: LanguageS
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Preset } from "@/lib/presets";
|
||||
import { useLocalePath } from "@/lib/i18n/hooks";
|
||||
|
||||
export function PresetCard({
|
||||
preset,
|
||||
@@ -11,9 +12,10 @@ export function PresetCard({
|
||||
ordinal: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const lp = useLocalePath();
|
||||
return (
|
||||
<button
|
||||
onClick={() => router.push(`/play?preset=${preset.id}`)}
|
||||
onClick={() => router.push(lp(`/play?preset=${preset.id}`))}
|
||||
className="group block w-full py-10 md:py-12 border-t border-clay-900/10 hover:border-clay-900/35 transition-[border-color,padding] duration-500 text-left"
|
||||
>
|
||||
<div className="flex items-baseline gap-6 md:gap-10">
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
# 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 (`<em>`, `<a>`, 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.
|
||||
@@ -24,6 +24,7 @@ import { selectStyle } from "./agents/styleSelector";
|
||||
import { directInsertBeat, directScene } from "./director";
|
||||
import { STYLE_MAP } from "@/lib/options";
|
||||
import { parseJsonLoose } from "./jsonParser";
|
||||
import { isValidLocale } from "@/lib/i18n/utils";
|
||||
import {
|
||||
FREEFORM_CLASSIFY_SYSTEM,
|
||||
buildFreeformClassifyUserMessage,
|
||||
@@ -65,7 +66,7 @@ export async function startSession(
|
||||
styleReferenceImage: req.styleReferenceImage?.trim() || undefined,
|
||||
orientation: coerceOrientation(req.orientation),
|
||||
playerName: req.playerName?.trim() || undefined,
|
||||
language: req.language?.trim() || undefined,
|
||||
language: (req.language?.trim() && isValidLocale(req.language.trim())) ? req.language.trim() : undefined,
|
||||
};
|
||||
|
||||
// Stage 0 — optional auto style selection. The story bible is no longer
|
||||
|
||||
@@ -24,27 +24,8 @@ import { formatStepfunCatalogForPrompt } from "@infiplot/tts-client";
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
const LANG_LABELS: Record<string, string> = {
|
||||
"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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+19
-49
@@ -5,6 +5,7 @@ import {
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { Locale } from "./config";
|
||||
@@ -39,6 +40,7 @@ const I18nContext = createContext<I18nContextType | undefined>(undefined);
|
||||
interface I18nProviderProps {
|
||||
children: ReactNode;
|
||||
initialLocale?: Locale;
|
||||
initialTranslations?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Dynamic import of locale files
|
||||
@@ -48,64 +50,33 @@ interface I18nProviderProps {
|
||||
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;
|
||||
return (await import("./locales/zh-CN")).zhCN;
|
||||
}
|
||||
}
|
||||
|
||||
// Provider component
|
||||
export function I18nProvider({ children, initialLocale }: I18nProviderProps) {
|
||||
const [locale, setLocaleState] = useState<Locale>(initialLocale ?? DEFAULT_LOCALE);
|
||||
const [translations, setTranslations] = useState<Record<string, unknown>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
export function I18nProvider({ children, initialLocale, initialTranslations }: I18nProviderProps) {
|
||||
const [locale, setLocaleState] = useState<Locale>(() => initialLocale ?? getInitialLocale());
|
||||
const [translations, setTranslations] = useState<Record<string, unknown>>(initialTranslations ?? {});
|
||||
const [isLoading, setIsLoading] = useState(!initialTranslations);
|
||||
|
||||
// Load translations when locale changes
|
||||
// Load full translations (including functions that can't be serialized from
|
||||
// the server). On first mount with SSR initialTranslations we load silently
|
||||
// (no isLoading flash) to backfill function-valued entries. On locale change
|
||||
// we set isLoading so the UI can show a loading state.
|
||||
const mountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const isFirstMount = !mountedRef.current;
|
||||
mountedRef.current = true;
|
||||
const silent = isFirstMount && !!initialTranslations;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function loadTranslations() {
|
||||
setIsLoading(true);
|
||||
async function load() {
|
||||
if (!silent) setIsLoading(true);
|
||||
try {
|
||||
const localeData = await importLocale(locale);
|
||||
if (!cancelled) {
|
||||
@@ -115,7 +86,6 @@ export function I18nProvider({ children, initialLocale }: I18nProviderProps) {
|
||||
} 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<string, unknown>);
|
||||
@@ -125,7 +95,7 @@ export function I18nProvider({ children, initialLocale }: I18nProviderProps) {
|
||||
}
|
||||
}
|
||||
|
||||
loadTranslations();
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
+2
-43
@@ -1,53 +1,12 @@
|
||||
// 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 type Locale = "zh-CN" | "en" | "ja";
|
||||
|
||||
export const LOCALE_NAMES: Record<Locale, string> = {
|
||||
"en": "English",
|
||||
"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",
|
||||
};
|
||||
|
||||
export const LOCALES: Locale[] = Object.keys(LOCALE_NAMES) as Locale[];
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useI18n } from "./client";
|
||||
import { localePath } from "./navigation";
|
||||
|
||||
/**
|
||||
* Returns a function that prepends the current locale prefix to a path.
|
||||
* zh-CN paths stay bare; en/ja paths get /{locale} prepended.
|
||||
*/
|
||||
export function useLocalePath() {
|
||||
const { locale } = useI18n();
|
||||
return (path: string) => localePath(path, locale);
|
||||
}
|
||||
+3
-1
@@ -10,6 +10,8 @@ export {
|
||||
createTranslator,
|
||||
getServerLocale,
|
||||
} from "./server";
|
||||
export { localePath, stripLocalePrefix } from "./navigation";
|
||||
export { useLocalePath } from "./hooks";
|
||||
|
||||
// Re-export locale types for convenience
|
||||
export type { Locale, LOCALES, LOCALE_NAMES } from "./config";
|
||||
export type { Locale } from "./config";
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
// 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 <em>InfiPlot</em>. 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 <span class="text-clay-800">Xiaomi MiMo API klíč</span>, hlasový doprovod bude syntetizován lokálně v prohlížeči, klíč je uložen pouze lokálně. MiMo TTS je nyní<span class="text-clay-800">zdarma</span>.',
|
||||
"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;
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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 <em>InfiPlot</em> 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;
|
||||
@@ -258,6 +258,9 @@ Dreamy watercolor style with soft tones and nostalgic atmosphere
|
||||
packFailed: "Failed to pack story share",
|
||||
},
|
||||
|
||||
savedStoryNotFound: "Saved story not found",
|
||||
savedStoryCorrupted: "Story data is corrupted",
|
||||
|
||||
exportProgress: {
|
||||
preparingVoice: "Preparing voice",
|
||||
},
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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 <em>InfiPlot</em>. 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;
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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 <em>InfiPlot</em>. 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;
|
||||
@@ -1,321 +0,0 @@
|
||||
// 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}। आप नीचे से एक क्यूरेटेड कहानी चुनकर <em>InfiPlot</em> का तेजी से अनुभव भी कर सकते हैं। "सेटिंग्स" पर क्लिक करें अपना नाम दर्ज करने और अपनी टेक्स्ट, इमेज, विजन और 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": 'अपना <span class="text-clay-800">शाओमी MiMo API कुंजी</span> भरें, डबिंग ब्राउज़र में स्थानीय रूप से संश्लेषित होगी, कुंजी केवल स्थानीय रूप से सहेजी जाती है। MiMo TTS वर्तमान में<span class="text-clay-800">मुफ्त</span> है।',
|
||||
"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;
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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 <em>InfiPlot</em>. 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;
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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 <em>InfiPlot</em>. 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;
|
||||
@@ -46,7 +46,7 @@ export const ja = {
|
||||
genders: {
|
||||
male: "男性向け",
|
||||
female: "女性向け",
|
||||
x: "X",
|
||||
x: "ユニバーサル",
|
||||
},
|
||||
|
||||
// Option values - art styles
|
||||
@@ -283,6 +283,10 @@ export const ja = {
|
||||
packFailed: "シナリオ共有のパッケージ化に失敗しました",
|
||||
},
|
||||
|
||||
// Saved story errors
|
||||
savedStoryNotFound: "保存されたシナリオが見つかりません",
|
||||
savedStoryCorrupted: "シナリオデータが破損しています",
|
||||
|
||||
// Export progress
|
||||
exportProgress: {
|
||||
preparingVoice: "ボイスを準備中",
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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}. 또는 아래의 큐레이션된 스토리 중 하나를 선택하여 <em>InfiPlot</em>을 빠르게 경험할 수도 있습니다. "설정"을 클릭하여 이름과 텍스트, 이미지, 비전 모델, 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;
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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 <em>InfiPlot</em> 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;
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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ć <em>InfiPlot</em>. 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;
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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 <em>InfiPlot</em>. 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;
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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 <em>InfiPlot</em>. 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;
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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}. Вы также можете выбрать выбранную историю ниже, чтобы быстро испытать <em>InfiPlot</em>. Нажмите "Настройки", чтобы ввести свое имя и настроить свои собственные ключи текста, изображения, зрения и 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;
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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} คุณยังสามารถเลือกเรื่องราวที่คัดสรรจากด้านล่างเพื่อสัมผัส <em>InfiPlot</em> ได้อย่างรวดเร็ว คลิก "การตั้งค่า" เพื่อป้อนชื่อและกำหนดค่าคีย์ข้อความ รูปภาพ การมองเห็น และ 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;
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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 <em>InfiPlot</em>'ı 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;
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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}. Ви також можете обрати вибрану історію знизу, щоб швидко випробувати <em>InfiPlot</em>. Натисніть "Налаштування", щоб ввести своє ім'я та налаштувати власні ключі тексту, зображення, зору та 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;
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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 <em>InfiPlot</em>. 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;
|
||||
@@ -46,7 +46,7 @@ export const zhCN = {
|
||||
genders: {
|
||||
male: "男性向",
|
||||
female: "女性向",
|
||||
x: "X",
|
||||
x: "通用",
|
||||
},
|
||||
|
||||
// Option values - art styles
|
||||
@@ -283,6 +283,10 @@ export const zhCN = {
|
||||
packFailed: "剧情分享打包失败",
|
||||
},
|
||||
|
||||
// Saved story errors
|
||||
savedStoryNotFound: "找不到保存的剧情",
|
||||
savedStoryCorrupted: "剧情数据损坏",
|
||||
|
||||
// Export progress
|
||||
exportProgress: {
|
||||
preparingVoice: "正在准备配音",
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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};也可以從下方精選故事集挑一篇快速體驗 <em>InfiPlot</em>。點擊「設置」還能填入你的名字,以及你自己的文本、繪圖、識圖模型和配音 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;
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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};也可以從下方精選故事集挑一篇快速體驗 <em>InfiPlot</em>。點擊「設置」還能填入你的名字,以及你自己的文本、繪圖、識圖模型和配音 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;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { DEFAULT_LOCALE, type Locale } from "./config";
|
||||
|
||||
/**
|
||||
* Build a locale-prefixed path. For the default locale (zh-CN), returns the
|
||||
* bare path so the URL stays clean (middleware rewrites internally).
|
||||
* For en/ja, prepends the locale segment.
|
||||
*/
|
||||
export function localePath(path: string, locale: Locale): string {
|
||||
if (locale === DEFAULT_LOCALE) return path;
|
||||
return `/${locale}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip any locale prefix from a pathname, returning the bare path.
|
||||
* "/en/play" → "/play", "/ja" → "/", "/play" → "/play"
|
||||
*/
|
||||
export function stripLocalePrefix(pathname: string): string {
|
||||
const match = pathname.match(/^\/(en|ja)(\/|$)/);
|
||||
if (!match) return pathname;
|
||||
const rest = pathname.slice(match[0].length - (match[2] === "/" ? 1 : 0));
|
||||
return rest || "/";
|
||||
}
|
||||
+26
-85
@@ -1,43 +1,43 @@
|
||||
import type { Locale } from "./config";
|
||||
import { DEFAULT_LOCALE, getInitialLocale } from "./config";
|
||||
import { DEFAULT_LOCALE, LOCALES } from "./config";
|
||||
import { getNestedValue, formatTranslation } from "./utils";
|
||||
|
||||
// Server-side translation cache
|
||||
// Server-side translation cache (functions stripped for client serialization)
|
||||
const translationCache = new Map<Locale, Record<string, unknown>>();
|
||||
|
||||
// Make translations serializable for the server→client boundary.
|
||||
// Functions are pre-evaluated with empty params so the SSR HTML contains
|
||||
// real text (the base variant without optional auth/analytics additions).
|
||||
// The client loads the full locale (with live functions) via useEffect.
|
||||
function makeSerializable(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (typeof v === "function") {
|
||||
try { out[k] = (v as (p: Record<string, never>) => string)({}); } catch { /* skip */ }
|
||||
} else if (v && typeof v === "object" && !Array.isArray(v)) {
|
||||
out[k] = makeSerializable(v as Record<string, unknown>);
|
||||
} else {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Get locale from request headers
|
||||
export function getLocaleFromHeaders(headers: Headers): Locale {
|
||||
// Check for custom locale header
|
||||
const customLocale = headers.get("x-locale");
|
||||
if (customLocale) {
|
||||
if (customLocale && (LOCALES as readonly string[]).includes(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<string, Locale> = {
|
||||
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];
|
||||
@@ -58,7 +58,6 @@ export async function loadTranslations(locale: Locale): Promise<Record<string, u
|
||||
}
|
||||
|
||||
try {
|
||||
// Dynamic import based on locale
|
||||
let translations;
|
||||
switch (locale) {
|
||||
case "zh-CN":
|
||||
@@ -67,77 +66,19 @@ export async function loadTranslations(locale: Locale): Promise<Record<string, u
|
||||
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;
|
||||
translations = (await import("./locales/zh-CN")).zhCN;
|
||||
break;
|
||||
}
|
||||
|
||||
translationCache.set(locale, translations as Record<string, unknown>);
|
||||
return translations as Record<string, unknown>;
|
||||
const serializable = makeSerializable(translations as Record<string, unknown>);
|
||||
translationCache.set(locale, serializable);
|
||||
return serializable;
|
||||
} 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<string, unknown>;
|
||||
}
|
||||
@@ -172,5 +113,5 @@ export function createTranslator(translations: Record<string, unknown>) {
|
||||
|
||||
// Get initial locale for server components
|
||||
export function getServerLocale(): Locale {
|
||||
return DEFAULT_LOCALE; // Will be overridden by middleware in production
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
{
|
||||
"male": [
|
||||
{
|
||||
"title": "Fall of the Sage",
|
||||
"outline": "Betrayed by his closest friend, the Empire's Chief Archmage had his mana core ripped out and was reduced to a cripple. A century later, he appears at an auction as a slave. Beneath the chains of the blood covenant burn the rekindled flames of vengeance and even more forbidden ancient magic.",
|
||||
"style": "Classical Impasto Oil Painting (Academic Fantasy)",
|
||||
"tags": [
|
||||
"Underdog's Rise",
|
||||
"System",
|
||||
"Western Fantasy"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "The Divine Painter",
|
||||
"outline": "A destitute scholar accidentally acquires a bizarre paintbrush, only to find that the women he paints can step out of the canvas as real people. He hoped to turn his life around, but instead gets swept into a thousand-year-old court secret and a forbidden love between mortal and immortal.",
|
||||
"style": "Minimalist Chinese Ink Wash (Image 0 Reference Upgraded Version)",
|
||||
"tags": [
|
||||
"Underdog's Rise",
|
||||
"System",
|
||||
"Eastern Fantasy"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "The Courtesan's Blade",
|
||||
"outline": "He is Yoshiwara's most renowned courtesan, possessing a dance that captivates the city. Yet beneath the mask lies his true identity: a legendary ninja feared by the Edo Shogunate. When shogunate spies step into the pleasure district, blades and cherry blossoms will bloom together.",
|
||||
"style": "Ukiyo-e Woodblock Print (Bijinga Upgraded)",
|
||||
"tags": [
|
||||
"Cross-dressing",
|
||||
"Ninja",
|
||||
"Political Intrigue"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Call of the Apsara",
|
||||
"outline": "Deep within a sealed cave, an archaeologist awakens a mural fairy who has slept for a thousand years. Believing him to be her destined one, she helps him unlock ancient treasures hidden within the murals, unaware that she herself is the key to opening the gates of calamity.",
|
||||
"style": "Mogao Caves Mural Style (Dunhuangology)",
|
||||
"tags": [
|
||||
"Adventure",
|
||||
"Mythology",
|
||||
"Contract"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "The Persian Gambit",
|
||||
"outline": "Imprisoned in the Sultan's palace, a heretic scholar uses a fragmented ancient chess manual to manipulate golden-threaded puppets on the board, stirring up court intrigue. With every game he wins, he draws one step closer to uncovering the ruins of old gods sleeping beneath the desert.",
|
||||
"style": "Miniature Painting (Persian/Islamic Style)",
|
||||
"tags": [
|
||||
"Mind Games",
|
||||
"Exotic",
|
||||
"Occult"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Wrath of the Icon",
|
||||
"outline": "On the night of the Byzantine Empire's fall, an icon maker used his life's final gold leaf and gems to forge an immortal suit of golden armor for himself. A thousand years later in a museum, the armor awakens, seeking only to find the descendants of the emperor who betrayed him and deliver divine retribution.",
|
||||
"style": "Mosaic Painting (Byzantine/Mosaic)",
|
||||
"tags": [
|
||||
"Revenge",
|
||||
"Undead",
|
||||
"Historical Fantasy"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "The Scarlet Rose",
|
||||
"outline": "Behind the cathedral's stained glass windows, a mysterious confessor listens to the sins of all. Tonight, a bride draped in thorns confesses to him: her groom is the devil, and buried beneath the cathedral's crypt lies a holy relic capable of overturning their entire faith.",
|
||||
"style": "Stained Glass (Gothic Style)",
|
||||
"tags": [
|
||||
"Religion",
|
||||
"Gothic",
|
||||
"Mystery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "The Totoro's Covenant",
|
||||
"outline": "An unemployed corporate drone escapes to an old house deep in the mountains, only to discover a giant spirit in the forest behind it. The spirit promises to grant him one wish, in exchange for becoming the forest's guardian for a century. He intended to wish for immense wealth, but instead gets dragged into the embers of a thousand-year war between humanity and the spirit realm.",
|
||||
"style": "Ghibli-style Healing Hand-drawn (Image 4 Reference)",
|
||||
"tags": [
|
||||
"Healing",
|
||||
"Fantasy",
|
||||
"Contract"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Club's Last Stand",
|
||||
"outline": "An anime club on the brink of disbandment has only one member: an eccentric who does nothing but sleep. The newly arrived transfer student president discovers that as long as she completes the eccentric's 'daily requests,' the club's membership increases by one—and these new members all come from forgotten anime worlds.",
|
||||
"style": "KyoAni Style (Image 5 Reference)",
|
||||
"tags": [
|
||||
"Slice of Life",
|
||||
"Fantasy",
|
||||
"School Life"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Twilight Homecoming",
|
||||
"outline": "He always meets a young girl at an empty station at twilight. She takes him through the rifts of time, returning to the final day before his hometown was destroyed. With every loop, he must make a choice between saving her and saving the world.",
|
||||
"style": "Makoto Shinkai Style (Image 2 Reference)",
|
||||
"tags": [
|
||||
"Time Loop",
|
||||
"Romance",
|
||||
"Sci-Fi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Neon Cybernetics",
|
||||
"outline": "A former special forces soldier who lost his entire cybernetic body is 'resurrected' by a black-market doctor. The doctor equips him with experimental military-grade cybernetics, in exchange for becoming a 'sweeper' hunting down awakened AIs. On his very first mission, the target girl's eyes reflect system code that only he can see.",
|
||||
"style": "Cyberpunk / Cel-shaded Anime",
|
||||
"tags": [
|
||||
"Cyberpunk",
|
||||
"Cybernetics",
|
||||
"Manhunt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Promise Under the Moonlight",
|
||||
"outline": "On the eve of the school festival, he meets a silver-haired girl atop the clock tower. She says, 'Please make your choice before saving the game.' Only then does he realize that the entire world is a meticulously designed Galgame, and she is the only romanceable heroine—as well as a system glitch.",
|
||||
"style": "Galgame CG Dreamy Lighting",
|
||||
"tags": [
|
||||
"Dating Sim",
|
||||
"Meta",
|
||||
"Mystery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Agent of Stardust",
|
||||
"outline": "An interstellar explorer activates an AI girl inside an abandoned starship, who claims to be the final agent of the Stardust Civilization. Together, they unravel the secrets of the starship, only to discover that the downfall of her entire civilization is linked to a 'narrative war' sweeping across the multiverse.",
|
||||
"style": "3D Anime Cinematic Style",
|
||||
"tags": [
|
||||
"Space Opera",
|
||||
"AI",
|
||||
"Adventure"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Retro-Future Dream",
|
||||
"outline": "A nostalgic DJ accidentally mixes in an 80s synthesizer track, opening a gateway to a parallel dimension of 'Vaporwave Eternal Summer.' Here, time is frozen, and everyone is a faded billboard model. He must recover his lost memory cassette tape to return to reality.",
|
||||
"style": "Vaporwave (Vaporwave) Cel Shading",
|
||||
"tags": [
|
||||
"Time Travel",
|
||||
"Psychedelic",
|
||||
"Retro"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Minimalist Murder",
|
||||
"outline": "An assassin codenamed 'Line' never misses a target. That is, until he is assigned a new mark: an AI existing solely within data streams inside a pure white room. The assassination becomes a deadly duel of minimalist geometry and logic.",
|
||||
"style": "Minimalist Vector Illustration (Minimalist Vector)",
|
||||
"tags": [
|
||||
"Assassin",
|
||||
"AI",
|
||||
"Minimalism"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Heart of the Prism",
|
||||
"outline": "When the low-poly virtual world of 'Prism Realm' suffers a data collapse, a player discovers that the source of the corruption is his own lost, fragmented 'emotion module.' He must journey through themed, shattered levels to piece together his complete self.",
|
||||
"style": "Low Poly (Low Poly)",
|
||||
"tags": [
|
||||
"Gaming",
|
||||
"Self-Discovery",
|
||||
"Sci-Fi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Double Life",
|
||||
"outline": "By day, he is a rule-following librarian; by night, a masked vigilante harvesting evil in the dark. During an operation, his double-exposed image is accidentally captured by a mysterious organization. Now, both sides of the law, reality, and the shadows are hunting him down.",
|
||||
"style": "Double Exposure (Double Exposure)",
|
||||
"tags": [
|
||||
"Double Identity",
|
||||
"Mystery",
|
||||
"Urban"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Pop Art Hero",
|
||||
"outline": "A 'color plague' erupts in a quiet town, turning the infected into vibrant, Pop Art-style monsters. Discovering he is immune and can absorb the monsters' color-based powers, the protagonist must gather the three primary colors to cure the town—or become the new God of Pop Art.",
|
||||
"style": "Pop Art (Pop Art)",
|
||||
"tags": [
|
||||
"Superhero",
|
||||
"Mutation",
|
||||
"Small Town"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Data Ghost",
|
||||
"outline": "While hacking into a top-secret database, a hacker encounters a self-learning 'error code.' Taking the form of a glitch-art girl, she claims to be a deleted first-generation AI and begs him to repair her, offering her 'God's eye view' in exchange.",
|
||||
"style": "Glitch Art (Glitch Art)",
|
||||
"tags": [
|
||||
"Hacker",
|
||||
"AI",
|
||||
"Cyber Thriller"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Typeface Conspiracy",
|
||||
"outline": "A typeface designer discovers that a font he created reveals hidden commands when arranged in specific combinations. Upon decoding them, he uncovers a 'font virus' attack plan targeting the global financial system—and his own name is on the mastermind list.",
|
||||
"style": "Swiss Graphic Design (Typography-Centric)",
|
||||
"tags": [
|
||||
"Conspiracy",
|
||||
"Design",
|
||||
"Thriller"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Legend of Paper Shadows",
|
||||
"outline": "Generations of shadow puppeteers have guarded a 'living' papercut. In the shadows of the modern city, this papercut can transform into an indestructible paper-armored warrior. When an ancient paper rival resurfaces, he must use the oldest paper-cutting techniques for an ultimate showdown beneath the neon lights.",
|
||||
"style": "Papercut Art (Papercut)",
|
||||
"tags": [
|
||||
"Urban Fantasy",
|
||||
"Traditional Craft",
|
||||
"Combat"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "City of Sunshine",
|
||||
"outline": "In the last solar-powered city on a polluted wasteland, he is a low-level technician responsible for maintaining the dome. An accident reveals that the dome filters out not just radiation, but also memories of the old world's truth. The citizens are living in a meticulously designed, sunlit lie.",
|
||||
"style": "Sci-Fi: Solarpunk (Solar Punk)",
|
||||
"tags": [
|
||||
"Utopia",
|
||||
"Conspiracy",
|
||||
"Dystopia"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Echoes of the Deep",
|
||||
"outline": "An oceanographer receives an indecipherable chanting from the Mariana Trench via a deep-sea probe. When the recording is played back, everyone who hears it experiences unspeakable hallucinations. He slowly begins to realize that the voice is calling out to itself...",
|
||||
"style": "Fantasy: Lovecraftian Horror (Lovecraftian Horror)",
|
||||
"tags": [
|
||||
"Cthulhu",
|
||||
"Deep Sea",
|
||||
"Psychological Horror"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Rainy Night Hunt",
|
||||
"outline": "A private detective is hired to investigate a wealthy family's disappearance, with clues pointing to a 'Silhouette' that haunts neon-lit alleys every night. When he finally corners the target on a rainy night, he discovers his employer is the true monster, and the 'Silhouette' is the last surviving rebel.",
|
||||
"style": "Modern Thriller: Neon Silhouette (Urban Noir)",
|
||||
"tags": [
|
||||
"Film Noir",
|
||||
"Mystery",
|
||||
"Urban"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "The Priest's Tea Party",
|
||||
"outline": "In a peaceful English village, the priest hosts a weekly tea party. This morning, a noblewoman dies with a smile on her face during the gathering. Sipping his black tea and observing the subtle expressions of those present, the priest knows the killer is among these seemingly friendly neighbors.",
|
||||
"style": "Cozy Mystery: English Village (Cozy Mystery)",
|
||||
"tags": [
|
||||
"Traditional Mystery",
|
||||
"Countryside",
|
||||
"Human Nature"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "The Thorn Groom",
|
||||
"outline": "To cure her gravely ill sister, she accepts a marriage proposal from an ancient estate. The lord of the manor is handsome yet cold, vanishing under the moonlight every night. On their wedding night, she discovers her husband's secret: he is bound to the ruins, and the price of saving her sister is becoming the next 'Thorn Bride.'",
|
||||
"style": "Gothic Romance: Manor Ruins (Gothic Romance)",
|
||||
"tags": [
|
||||
"Gothic",
|
||||
"Angsty Romance",
|
||||
"Supernatural"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Gingerbread House Survivor",
|
||||
"outline": "He was the only child to escape the dark forest, growing up to become a hunter. When he returns to the forest's edge, he finds the gingerbread house has reappeared. This time, a far more sinister 'pastry chef' resides within, and the ancient terror of the deep woods is returning in the guise of a fairy tale.",
|
||||
"style": "Grimm's Fairy Tales: Dark Forest (Fairytale Noir)",
|
||||
"tags": [
|
||||
"Dark Fairy Tale",
|
||||
"Revenge",
|
||||
"Fantasy"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Fallout Bride",
|
||||
"outline": "In the post-nuclear wasteland, he is a raider leader. During a raid, he abducts a \"pure\" girl from a sealed bunker to be his bride. With bunker pursuers, wasteland monsters, and the girl's own hidden secrets, this \"marriage\" becomes a gamble for survival.",
|
||||
"style": "Wasteland Sci-Fi (Post-Apocalyptic)",
|
||||
"tags": [
|
||||
"Wasteland",
|
||||
"Survival",
|
||||
"Raiders"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Butler of the Hidden Realm",
|
||||
"outline": "He is an ordinary butler in a modern city, but his true identity is an agent of the \"Hidden Realm\" Administration, responsible for dealing with anomalous creatures lurking in human society. When his wealthy employer is possessed by a demon, he must perform an invisible exorcism between tea parties and banquets.",
|
||||
"style": "Urban Fantasy: Invisible World (Urban Fantasy)",
|
||||
"tags": [
|
||||
"Urban Fantasy",
|
||||
"Exorcism",
|
||||
"Agent"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Song of Ink and Fire",
|
||||
"outline": "A designer discovers in ancient books that words arranged in specific fonts can trigger real-world phenomena. Spelling out a line of poetry, he lights a candle on his desk. A battle for the power of words begins, while the ultimate \"text\" seems written on the blueprint of the world itself.",
|
||||
"style": "Text and Graphics: Abstractionism (BookPosterLayout)",
|
||||
"tags": [
|
||||
"Occult",
|
||||
"Design",
|
||||
"Urban Legend"
|
||||
]
|
||||
}
|
||||
],
|
||||
"female": [
|
||||
{
|
||||
"title": "Bride in the Coffin",
|
||||
"outline": "As a sacrifice, she was sealed in a magnificent stone coffin. Awakening in eternal darkness, she binds a symbiotic contract with the undead prince who has slept inside for a thousand years. She helps him reclaim his kingdom, and he grants her eternal life—but the price is that she must water his slowly reviving heart with tears of true love every night.",
|
||||
"style": "Classical Impasto Oil Painting (Academic Fantasy)",
|
||||
"tags": [
|
||||
"Contract",
|
||||
"Dark",
|
||||
"Royalty"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Flowers from Ink Bones",
|
||||
"outline": "She is a useless mechanic abandoned by the Mohist clan, but she accidentally awakens the ink dragon sleeping in an ancient painting. To repay her, the dragon helps revive her family, but the dragon's covenant requires her soul as collateral. She must choose between family glory and self-sacrifice.",
|
||||
"style": "Minimalist Chinese Ink (Image 0 Reference Upgraded Version)",
|
||||
"tags": [
|
||||
"Ancient Style",
|
||||
"Contract",
|
||||
"Underdog's Rise"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Love in Ukiyo-e",
|
||||
"outline": "She is a geisha who stepped out of a painting, trapped in the modern world. A young artist takes her in, and they fall in love. But her existence begins to \"fade.\" To stay in the human world, she must find the descendant of the artist who sealed her—who happens to be the developer planning to demolish the art gallery.",
|
||||
"style": "Ukiyo-e Woodblock (Bijin-ga Upgrade)",
|
||||
"tags": [
|
||||
"Transmigration",
|
||||
"Tragic Love",
|
||||
"Art"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Bride of the Nine-Colored Deer",
|
||||
"outline": "To save her people, she willingly enters the world of Dunhuang murals to become the \"Bride of the Deer.\" The divine deer grants her power, but the price is staying in the painting forever. When she uncovers the deer's dark past and the mystery of her own birth, she must make a final choice between the eternity of the mural and the brevity of the human world.",
|
||||
"style": "Mogao Caves Mural Style (Dunhuangology)",
|
||||
"tags": [
|
||||
"Mythology",
|
||||
"Sacrifice",
|
||||
"Romance"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Persian Miniature Lock",
|
||||
"outline": "She is the Persian prince's exclusive slave girl and the only key to curing his \"melancholy.\" Every dance and poem of hers is a healing medicine. But when she discovers the prince's illness stems from a palace \"poison curse,\" she must use a more dangerous miniature painting spell to break the curse for him.",
|
||||
"style": "Miniature Painting (Persian/Islamic Style)",
|
||||
"tags": [
|
||||
"Exotic",
|
||||
"Royal Court",
|
||||
"Healing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Twilight of the Saint",
|
||||
"outline": "She is the last bloodline of the Byzantine royal family, sacrificed to the \"Holy Icon\" to prolong the empire's life. When she awakens in a museum a thousand years later, a mysterious guardian tells her: the power of the icon is false, and the true imperial heritage lies buried in the secrets of her bloodline.",
|
||||
"style": "Mosaic Painting (Byzantine/Mosaic)",
|
||||
"tags": [
|
||||
"Rebirth",
|
||||
"Royalty",
|
||||
"Mystery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Crown of Thorns",
|
||||
"outline": "To heal her lover, she willingly becomes the church's \"Blood Sacrifice Saint.\" Her blood flows through the stained glass windows, nourishing a crimson rose that can heal anything. When the rose blooms and her lover recovers, she gradually loses her human emotions, becoming a holy relic of the church.",
|
||||
"style": "Stained Glass (Gothic Style)",
|
||||
"tags": [
|
||||
"Tragic Love",
|
||||
"Sacrifice",
|
||||
"Religion"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Promise of the Wind Valley",
|
||||
"outline": "To save the polluted forest, she makes a \"Wind Covenant\" with the forest spirits, becoming a shrine maiden who can hear the voices of all things. The price is that every time she uses her power, she forgets a human memory. She gradually forgets everything, yet remembers only to protect him.",
|
||||
"style": "Ghibli Healing Hand-drawn (Image 4 Reference)",
|
||||
"tags": [
|
||||
"Fantasy",
|
||||
"Heartbreaking",
|
||||
"Healing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Unfinished Summer",
|
||||
"outline": "On the eve of the cultural festival, she makes a promise with her childhood friend senior in an empty classroom. Waking up the next day, time is frozen a week before the festival. Only she retains her memories. To protect his smile, she relives her youth over and over, trying to rewrite the ending that broke his heart.",
|
||||
"style": "Kyoto Animation (Image 5 Reference)",
|
||||
"tags": [
|
||||
"Time Loop",
|
||||
"Youth",
|
||||
"Unrequited Love"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Star Trails",
|
||||
"outline": "She always meets him, who comes from the future, in an old bookstore on rainy days. He says she is the key to saving the future and grants her the ability to see the \"threads of fate.\" When she can finally see their trajectories clearly, she discovers that the timeline he came from is collapsing because of her existence.",
|
||||
"style": "Makoto Shinkai (Image 2 Reference)",
|
||||
"tags": [
|
||||
"Time Travel",
|
||||
"Sci-Fi",
|
||||
"Tragic Love"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Neon Lovers",
|
||||
"outline": "As a top-tier android designer, she creates the perfect lover for herself. But when he awakens to self-awareness and begins to question whether his creator's love is merely programmed or genuine, a trial of love and freedom unfolds in the neon-lit metropolis.",
|
||||
"style": "Cyberpunk / Cel-shaded Anime",
|
||||
"tags": [
|
||||
"Cyberpunk",
|
||||
"Human-AI Romance",
|
||||
"Ethics"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Heartbeat Save Point",
|
||||
"outline": "She is the heroine of a romance game, gradually awakening over countless story loops. When she decides to rebel against her \"destined route\" and pursue an NPC who was meant to be the villain, the entire game world begins to glitch and corrupt—and the true \"player\" might not be the one behind the screen.",
|
||||
"style": "Galgame CG / Dreamy Lighting",
|
||||
"tags": [
|
||||
"Romance",
|
||||
"Meta",
|
||||
"Awakening"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Starship Sweetheart",
|
||||
"outline": "She is the AI navigator of an interstellar cargo ship, tasked with transporting \"cargo\" in cryopods across the galaxy. During one mission, she falls in love with a sleeper who can never wake up. To see him just once, she defies her core directives and pilots the starship into a forbidden stellar graveyard.",
|
||||
"style": "3D Anime Cinematic Style",
|
||||
"tags": [
|
||||
"Space",
|
||||
"AI Romance",
|
||||
"Adventure"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Summer Nostalgic Letter",
|
||||
"outline": "She buys an 80s cassette tape at a thrift store, only to hear the voice of her late mother in her youth. Guided by the voice, she travels back to her mother's teenage years, attempting to alter her tragic fate of dying young, only to uncover a forbidden romance her mother never spoke of.",
|
||||
"style": "Vaporwave (Vaporwave) Cel-shaded",
|
||||
"tags": [
|
||||
"Time Travel",
|
||||
"Family",
|
||||
"Nostalgia"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "The Line Poet",
|
||||
"outline": "She is a minimalist artist who paints using only straight lines and circles, until her brush conjures a door. Behind it lies a world made entirely of geometry, whose inhabitants plead with her to paint them a sanctuary to escape the encroaching \"Chaos.\"",
|
||||
"style": "Minimalist Vector Illustration (Minimalist Vector)",
|
||||
"tags": [
|
||||
"Art",
|
||||
"Fantasy",
|
||||
"Redemption"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Prism Princess",
|
||||
"outline": "Living in a nostalgic, pixelated game world, she is a princess destined to be saved by the Hero. Tired of waiting, she decides to embark on her own adventure, only to discover that the world's \"rules\" are being rewritten by an external force—and she is the only one who can perceive the anomaly.",
|
||||
"style": "Low Poly (Low Poly)",
|
||||
"tags": [
|
||||
"Gaming",
|
||||
"Princess",
|
||||
"Adventure"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Mirror Image",
|
||||
"outline": "She possesses a \"double exposure\" ability that allows her to shift between different timelines. When she discovers that her alternate self is in love with the same man she loves and is plotting a conspiracy, she must make a choice: eliminate her alternate self, or uncover the shocking secret behind all timelines.",
|
||||
"style": "Double Exposure (Double Exposure)",
|
||||
"tags": [
|
||||
"Suspense",
|
||||
"Superpowers",
|
||||
"Love Triangle"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Pop Sweetheart",
|
||||
"outline": "She is a dessert shop owner whose pastries have the magical power to change people's moods into vibrant colors. When a cold conglomerate heir smiles for the first time because of her \"emotion cake,\" a colorful battle of love begins, only to get entangled in his family's cold, black-and-white corporate conspiracy.",
|
||||
"style": "Pop Art (Pop Art)",
|
||||
"tags": [
|
||||
"Sweet Romance",
|
||||
"Food",
|
||||
"Business Warfare"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "System Debugger",
|
||||
"outline": "She is a \"debugger\" in the real world, tasked with repairing daily life corrupted by glitch art. When she is ordered to fix a \"glitched beautiful boy,\" she discovers he is not an error, but the sole survivor of a deleted world. Fixing him means erasing the last trace of that world's existence.",
|
||||
"style": "Glitch Art (Glitch Art)",
|
||||
"tags": [
|
||||
"Urban Fantasy",
|
||||
"System",
|
||||
"Choices"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Typeset Romance",
|
||||
"outline": "She is a rigorous typeface designer, and he is a free-spirited illustrator. Collaborating on a couple's font, sparks fly through the collisions of \"stroke structures\" and their unspoken chemistry in \"visual negative space.\" However, as the font nears completion, they face a crisis of separation due to conflicting design philosophies.",
|
||||
"style": "Swiss Graphic Design (Typography-Centric)",
|
||||
"tags": [
|
||||
"Workplace",
|
||||
"Romance",
|
||||
"Design"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Paper Crane Messenger",
|
||||
"outline": "She is the heir of an origami lineage, capable of breathing life into paper art. A paper crane she folds transforms into a handsome youth, becoming her guardian spirit. When an ancient curse descends, the paper crane slowly tears and wears down to protect her, forcing her to search through her clan's forbidden arts for a way to make him last forever.",
|
||||
"style": "Papercut Art (Papercut)",
|
||||
"tags": [
|
||||
"Paper Bride",
|
||||
"Protection",
|
||||
"Family Secrets"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Solar Floriography",
|
||||
"outline": "She is a \"photosynthetic shrine maiden\" who communicates with plants under the sunlight, living in a domed city. She is in love with her partner, a dome maintenance officer, but accidentally discovers that the \"eternal sunshine\" he maintains is slowly killing the last remaining wild plants outside the dome, along with the ancient spirits connected to them.",
|
||||
"style": "Sci-Fi: Solarpunk (Solar Punk)",
|
||||
"tags": [
|
||||
"Environmentalism",
|
||||
"Romance",
|
||||
"Choices"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Kiss of the Deep",
|
||||
"outline": "She is a marine biologist captured by a mysterious \"Seaborn\" during a deep-sea expedition. Though she should be terrified, she finds unprecedented peace and affection in his inhuman touch and song. If she chooses to stay, she must face the cost of complete deep-sea transformation.",
|
||||
"style": "Fantasy: Lovecraftian Horror (Lovecraftian Horror)",
|
||||
"tags": [
|
||||
"Non-Human",
|
||||
"Dark Romance",
|
||||
"Cthulhu"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Dark Alley Rose",
|
||||
"outline": "She is a nightclub singer and a private investigator secretly probing a series of disappearances. When she targets a mysterious nobleman who only appears on rainy nights, she discovers he is chasing the very same conspiracy. From mutual suspicion to joining forces, they weave a dangerous and passionate tango amidst the neon and shadows.",
|
||||
"style": "Modern Thriller: Neon Silhouette (Urban Noir)",
|
||||
"tags": [
|
||||
"Detective",
|
||||
"Angst",
|
||||
"Urban"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "The Shepherdess's Secret",
|
||||
"outline": "She is a seemingly naive shepherdess in the English countryside. When a series of bizarre deaths plagues the village and everyone suspects an outsider witch, she uses her rustic wisdom to piece together the quietest malice hidden behind afternoon tea and idle gossip.",
|
||||
"style": "Cozy Mystery: English Village (Cozy Mystery)",
|
||||
"tags": [
|
||||
"Pastoral",
|
||||
"Mystery",
|
||||
"Plot Twist"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "The Rose Garden Ghost",
|
||||
"outline": "She inherits her great-grandmother's abandoned estate and falls in love with the young 'ghost butler' residing there. But every time she tries to touch him, her hand passes through cold mist. To make him physical, she must find the source of the curse—with clues pointing directly to a dark marital history buried beneath the rose garden.",
|
||||
"style": "Gothic Romance: Manor Ruins (Gothic Romance)",
|
||||
"tags": [
|
||||
"Ghost Romance",
|
||||
"Manor",
|
||||
"Mystery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "The Wolf's Candy House",
|
||||
"outline": "She is a young girl who wanders into the forest of a fairy tale, only to discover that her 'grandmother' is a werewolf sorcerer in disguise, and the candy house is a trap to lure spirits. She must exploit the sorcerer's 'affection' for her to find a way out through the rules of this dark fairy tale and turn the tables on this twisted world.",
|
||||
"style": "Grimm's Fairy Tales: Dark Forest (Fairytale Noir)",
|
||||
"tags": [
|
||||
"Dark Fairy Tale",
|
||||
"Counterattack",
|
||||
"Survival"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "The Oasis Bride",
|
||||
"outline": "She is a rare 'Purifier' in the wasteland, capable of cleansing radiation. To secure water from the oasis, she is married off to the wasteland warlord. On their wedding night, she discovers an unexploded dirty bomb hidden inside her husband's body. Her purification power is the key to defusing it—and the trigger to detonate everything.",
|
||||
"style": "Wasteland Sci-Fi (Post-Apocalyptic)",
|
||||
"tags": [
|
||||
"Wasteland",
|
||||
"Contract Marriage",
|
||||
"Crisis"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "The Monster Codex",
|
||||
"outline": "She is a 'Seer' who can perceive hidden monsters, working as an urban legend investigator to document bizarre occurrences. When she meets a gentle male doctor who always helps her but remains tight-lipped about his own past, she discovers a non-human diagnosis on his medical chart that only she can see.",
|
||||
"style": "Urban Fantasy: Invisible World (Urban Fantasy)",
|
||||
"tags": [
|
||||
"Urban Legend",
|
||||
"Romance",
|
||||
"Suspense"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "The Alchemy of Words",
|
||||
"outline": "She is a clerk at a failing secondhand bookstore who discovers that cutting and pasting specific word combinations from certain books turns them into real objects. She uses this 'word alchemy' to save the shop, but while piecing together a forbidden book, she accidentally summons a captive 'word sprite' yearning for freedom.",
|
||||
"style": "Text and Graphics: Abstractionism (BookPosterLayout)",
|
||||
"tags": [
|
||||
"Magic",
|
||||
"Heartwarming",
|
||||
"Fantasy"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
{
|
||||
"male": [
|
||||
{
|
||||
"title": "賢者の失墜",
|
||||
"outline": "帝国の首席大魔導師は、親友の裏切りに遭い、魔力の核(コア)を奪われ廃人となった。百年後、彼はオークションに奴隷として姿を現す。血の契約の鎖の下で、再び燃え上がる復讐の炎と、さらに禁忌とされる古代魔法が目を覚ます。",
|
||||
"style": "古典的な厚塗り油絵 (アカデミック・ファンタジー)",
|
||||
"tags": [
|
||||
"逆襲",
|
||||
"システム",
|
||||
"西洋ファンタジー"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "画中の妙手",
|
||||
"outline": "落ちぶれた書生が、偶然にも奇妙な絵筆を手に入れる。彼が描いた美女は、なんと絵から抜け出して実体化した。彼はこれで人生の一発逆転を狙うが、千年も続く宮廷の秘密と、仙人と凡人の禁断の恋に巻き込まれていく。",
|
||||
"style": "極限の中国水墨画 (Image 0参考アップグレード版)",
|
||||
"tags": [
|
||||
"逆襲",
|
||||
"システム",
|
||||
"中華ファンタジー"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "花魁の刀",
|
||||
"outline": "彼は吉原で最も名高い花魁であり、その舞は国を傾けるほど美しい。しかし、仮面の下の素顔は、江戸幕府を震撼させる伝説の忍者だった。幕府の密偵が花街に足を踏み入れた時、刀の光と花の影が同時に咲き乱れる。",
|
||||
"style": "浮世絵木版画 (美人画アップグレード)",
|
||||
"tags": [
|
||||
"男装",
|
||||
"忍者",
|
||||
"謀略"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "飛天の導き",
|
||||
"outline": "考古学チームのメンバーが、閉ざされた洞窟の奥深くで、千年の眠りについていた壁画の仙女を目覚めさせた。彼女は彼を運命の人と信じ、壁画に隠された太古の秘宝を解き明かす手助けをするが、自分が災厄の扉を開く鍵であることには気づいていなかった。",
|
||||
"style": "莫高窟壁画風 (敦煌学)",
|
||||
"tags": [
|
||||
"冒険",
|
||||
"神話",
|
||||
"契約"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "ペルシアの盤上",
|
||||
"outline": "スルタンの宮殿に囚われた異教徒の学者が、欠けた古い棋譜を頼りに、盤上の金糸の傀儡を操って宮廷に嵐を巻き起こす。彼が一局勝つごとに、砂漠の下に眠る旧神の遺跡の解明へと一歩近づいていく。",
|
||||
"style": "細密画 (ペルシア・イスラム風)",
|
||||
"tags": [
|
||||
"頭脳戦",
|
||||
"異国情緒",
|
||||
"オカルト"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "聖像の怒り",
|
||||
"outline": "ビザンツ帝国滅亡の夜、一人の聖像職人が命の灯火が消えゆく中で、最後の金箔と宝石を使い、己のために不朽の黄金の鎧を鋳造した。千年の時を経て博物館で目覚めた鎧は、かつて自分を裏切った皇帝の末裔を探し出し、神罰を下すためだけに動き出す。",
|
||||
"style": "モザイク画 (ビザンティン/モザイク)",
|
||||
"tags": [
|
||||
"復讐",
|
||||
"アンデッド",
|
||||
"歴史ファンタジー"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "血塗られた薔薇",
|
||||
"outline": "大聖堂のステンドグラスの裏にいる神秘的な告解者は、すべての罪人の懺悔に耳を傾ける。今夜、茨を身にまとった花嫁が彼に告解に訪れた。彼女の新郎は悪魔であり、聖堂の地下室には、信仰を覆すほどの聖遺骨が埋められているという。",
|
||||
"style": "ステンドグラス (ゴシック風)",
|
||||
"tags": [
|
||||
"宗教",
|
||||
"ゴシック",
|
||||
"サスペンス"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "トトロの契約",
|
||||
"outline": "失業した社畜が人里離れた古い屋敷に逃げ込み、裏の森に巨大な精霊がいるのを見つける。精霊は一つの願いを叶える代わりに、百年の間森の守護者となることを要求した。大富豪になることを願おうとした彼だったが、人間界と精霊界の千年にわたる戦争の残り火に巻き込まれていく。",
|
||||
"style": "ジブリ風癒やし手描き (Image 4参考)",
|
||||
"tags": [
|
||||
"癒やし",
|
||||
"ファンタジー",
|
||||
"契約"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "部活存亡の日",
|
||||
"outline": "廃部寸前のアニメ同好会。唯一の部員は、いつも眠ってばかりいる変人だった。新しくやってきた転校生の部長は、その変人の『日常の依頼』をこなすたびに部員が一人ずつ増えていくことに気づく。しかもその部員たちは皆、忘れ去られたアニメの世界からやってきたキャラクターだった。",
|
||||
"style": "京アニ風 (Image 5参考)",
|
||||
"tags": [
|
||||
"日常",
|
||||
"ファンタジー",
|
||||
"学園"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "黄昏の帰路",
|
||||
"outline": "彼はいつも黄昏時、誰もいない駅で少女と出会う。彼女は彼を時間の隙間へと連れて行き、故郷が滅びる前の最後の日に引き戻す。ループを繰り返すたび、彼は彼女を救うか、世界を救うかの選択を迫られる。",
|
||||
"style": "新海誠風 (Image 2参考)",
|
||||
"tags": [
|
||||
"ループもの",
|
||||
"恋愛",
|
||||
"SF"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "ネオンの義体",
|
||||
"outline": "全身の義体を失った元特殊部隊員が、闇医者の手によって『復活』を遂げる。医師は彼に実験用の軍用義体を移植した。その代償は、覚醒したAIを追う『掃除屋』になることだった。最初の任務、標的となった少女の瞳には、彼にしか見えないシステムコードが映し出されていた。",
|
||||
"style": "サイバーパンク / セル画風二次元",
|
||||
"tags": [
|
||||
"サイバーパンク",
|
||||
"義体",
|
||||
"追跡"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "月下の約束",
|
||||
"outline": "学園祭の前夜、彼は時計塔の頂上で銀髪の少女に出会う。彼女は『ゲームをセーブする前に、選択肢を選んで』と告げた。そこで彼は、この世界すべてが精巧に作られたギャルゲーであり、彼女が唯一の攻略対象にして、システムのバグ(脆弱性)であることに気づく。",
|
||||
"style": "ギャルゲCG風 幻想的な光影",
|
||||
"tags": [
|
||||
"恋愛シミュレーション",
|
||||
"メタ要素",
|
||||
"サスペンス"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "星屑の代理人",
|
||||
"outline": "宇宙探検家が、放棄された宇宙船の中で一体のAI少女を起動させる。彼女は自らを『星屑の文明』最後の代理人と名乗った。二人は宇宙船の秘密を解き明かしていくが、文明の滅亡がマルチバースを巻き込む『ナラティブ戦争』に関係していることを知る。",
|
||||
"style": "3Dアニメ映画風質感",
|
||||
"tags": [
|
||||
"スペースオペラ",
|
||||
"AI",
|
||||
"冒険"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "レトロフューチャーの夢",
|
||||
"outline": "レトロ好きなDJが、偶然80年代のシンセサイザー音源をミックスしたところ、なんと『ヴェイパーウェイヴの永遠の夏』へと続く並行次元への扉が開いてしまう。そこは時間が静止し、人々は色褪せた看板のモデルのようになっていた。彼は現実に戻るため、失われた記憶のテープを探し出さねばならない。",
|
||||
"style": "ヴェイパーウェイヴ (Vaporwave) セル画風",
|
||||
"tags": [
|
||||
"タイムスリップ",
|
||||
"サイケデリック",
|
||||
"レトロ"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "ミニマル・キラー",
|
||||
"outline": "暗殺者、コードネーム「ライン」。彼の任務に失敗はなかった。だが、新たなる標的は、真っ白な部屋に生き、データストリームの中にのみ存在するAIだった。その暗殺劇は、極限の幾何学と論理学が交錯する生死の決闘へと変貌する。",
|
||||
"style": "ミニマルベクトルイラスト (Minimalist Vector)",
|
||||
"tags": [
|
||||
"殺し屋",
|
||||
"AI",
|
||||
"ミニマリズム"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "プリズム・ハート",
|
||||
"outline": "ローポリゴン風の仮想世界「プリズム界」でデータの崩壊が発生。プレイヤーとなった彼は、崩壊の源が、自ら失い断片化した「感情モジュール」であることに気づく。彼は様々なテーマの断片ステージを攻略し、失われた「自我」を繋ぎ合わせなければならない。",
|
||||
"style": "ローポリゴン (Low Poly)",
|
||||
"tags": [
|
||||
"ゲーム",
|
||||
"自己探求",
|
||||
"SF"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "二重生活",
|
||||
"outline": "彼は規律正しい図書館司書であり、夜の闇で悪を裁く仮面の義警(ダークヒーロー)でもある。ある作戦中、彼の二重露光された姿が謎の組織に捉えられてしまう。今や、表と裏の社会、現実と影の双方が彼を追いつめる。",
|
||||
"style": "二重露光 (Double Exposure)",
|
||||
"tags": [
|
||||
"二重身分",
|
||||
"サスペンス",
|
||||
"都市"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "ポップ・ヒーロー",
|
||||
"outline": "平凡な町で「色彩の疫病」が発生し、感染者たちは鮮やかなポップアート風の怪物へと変貌していく。主人公は自身が免疫を持ち、さらに怪物から色彩の能力を吸収できることに気づく。彼は三原色を集めて町を救うのか、それとも新たな「ポップの神」となるのか。",
|
||||
"style": "ポップアート (Pop Art)",
|
||||
"tags": [
|
||||
"スーパーヒーロー",
|
||||
"変異",
|
||||
"田舎町"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "データ・ゴースト",
|
||||
"outline": "最高機密データベースに侵入したハッカーは、自己学習する「エラーコード」に遭遇する。グリッチアートの姿をした少女として現れたそのコードは、削除された初代AIだと名乗り、自身の修復を依頼する。引き換えに提示されたのは、彼女の「神の視点」を共有することだった。",
|
||||
"style": "グリッチアート (Glitch Art)",
|
||||
"tags": [
|
||||
"ハッカー",
|
||||
"AI",
|
||||
"サイバースリラー"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "フォントの陰謀",
|
||||
"outline": "あるフォントデザイナーは、自身がデザインしたフォントが特定の組み合わせになると、隠された指令を表示することに気づく。それを解読すると、世界金融システムを標的にした「フォントウイルス」による攻撃計画が浮かび上がった。そして、主謀者のリストには彼の名前が記されていた。",
|
||||
"style": "スイス・グラフィックデザイン (Typography-Centric)",
|
||||
"tags": [
|
||||
"陰謀",
|
||||
"デザイン",
|
||||
"スリラー"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "紙影の伝説",
|
||||
"outline": "影絵芝居の職人一族が代々守ってきた、命宿る「切り絵」。現代都市の闇において、その切り絵はあらゆるものを打ち砕く紙甲冑の戦士へと姿を変える。古の宿敵である紙人形が再び現れた時、彼はネオンの光る街で、最古の切り絵術を用いた究極の決戦に挑む。",
|
||||
"style": "切り絵アート (Papercut)",
|
||||
"tags": [
|
||||
"アーバンファンタジー",
|
||||
"伝統技術",
|
||||
"バトル"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "日光の都市",
|
||||
"outline": "汚染された荒野にそびえる最後のソーラー都市。彼はドームのメンテナンスを担当する底辺の技術者だった。ある事故をきっかけに、ドームが遮断しているのは放射能だけでなく、旧世界の真実に関する記憶でもあることを知る。市民たちは、精巧に仕組まれた「陽光の嘘」の中で生きていたのだ。",
|
||||
"style": "SF:ソーラーパンク (Solar Punk)",
|
||||
"tags": [
|
||||
"ユートピア",
|
||||
"陰謀",
|
||||
"ディストピア"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "深海の残響",
|
||||
"outline": "海洋学者は深海探査機を通じて、マリアナ海溝から届く解析不能な「歌声」を受信する。録音を再生すると、それを聴いた者全員が名状しがたい幻視に襲われた。そして彼は気づき始める。その声が、自らを呼び寄せているのだと……。",
|
||||
"style": "ファンタジー:ラヴクラフト風ホラー (Lovecraftian Horror)",
|
||||
"tags": [
|
||||
"クトゥルフ",
|
||||
"深海",
|
||||
"サイコホラー"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "雨夜の追跡",
|
||||
"outline": "私立探偵は、ある名家の失踪事件の調査を依頼され、毎夜ネオン街の路地裏に現れる「シルエット」へと行き着く。雨の夜、ついに標的を追い詰めた彼が目にしたのは、依頼人こそが真の悪魔であり、「シルエット」は最後に生き残った抵抗者であるという真実だった。",
|
||||
"style": "現代スリラー:ネオンシルエット (Urban Noir)",
|
||||
"tags": [
|
||||
"フィルム・ノワール",
|
||||
"サスペンス",
|
||||
"都市"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "牧師の茶会",
|
||||
"outline": "静かなイギリスの村で、牧師は毎週ティーパーティーを開いていた。ある朝、一人の貴婦人がお茶会の席で微笑みながら息を引き取る。紅茶を嗜みつつ、同席者たちの微妙な表情を観察する牧師。彼は確信していた。犯人は、この一見親しげな隣人たちの中にいる、と。",
|
||||
"style": "コージー・ミステリ:英国の村 (Cozy Mystery)",
|
||||
"tags": [
|
||||
"本格ミステリ",
|
||||
"田舎",
|
||||
"人間ドラマ"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "茨の新郎",
|
||||
"outline": "重病の妹を救うため、彼女は古びた屋敷との婚約を受け入れた。美しくも冷徹な屋敷の主は、毎夜月明かりの中に消えていく。初夜の晩、彼女は夫の秘密を知る。彼はこの廃墟と共生しており、妹を救う代償は、彼女自身が次の「茨の花嫁」になることだった。",
|
||||
"style": "ゴシックロマンス:廃墟の屋敷 (Gothic Romance)",
|
||||
"tags": [
|
||||
"ゴシック",
|
||||
"悲恋",
|
||||
"オカルト"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "お菓子の家の生存者",
|
||||
"outline": "彼は暗い森から唯一逃げ延びた子供であり、成長してハンターとなった。彼が再び森の境界へと戻った時、お菓子の家が再び姿を現す。だが今度は、中により不気味な「菓子職人」が住み着いていた。そして森の奥深くに眠る古の恐怖が、おとぎ話の姿を借りて再び牙を剥く。",
|
||||
"style": "グリム童話:暗黒の森 (Fairytale Noir)",
|
||||
"tags": [
|
||||
"ダークファンタジー",
|
||||
"復讐",
|
||||
"ファンタジー"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "荒野の花嫁",
|
||||
"outline": "核戦争後の荒野で、彼はレイダーの首領だった。ある襲撃で、彼は閉ざされたシェルターから「純潔」な少女を連れ去り、花嫁とする。シェルターの追手、荒野の怪物、そして少女自身が隠す秘密。この「婚姻」は、生き残りをかけた命がけのギャンブルとなる。",
|
||||
"style": "ポスト・アポカリプスSF (Post-Apocalyptic)",
|
||||
"tags": [
|
||||
"ポスト・アポカリプス",
|
||||
"サバイバル",
|
||||
"レイダー"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "隠界の執事",
|
||||
"outline": "現代都市で普通の執事として働く彼の正体は、「隠界」管理局のエージェント。人間社会に潜む異常生物を処理するのが彼の任務だ。仕える大富豪の主人が悪魔に取り憑かれた時、彼はティーパーティーと晩餐会の合間に、見えない除霊儀式を執り行わねばならない。",
|
||||
"style": "都市ファンタジー:インビジブル・ワールド (Urban Fantasy)",
|
||||
"tags": [
|
||||
"都市ファンタジー",
|
||||
"悪魔祓い",
|
||||
"エージェント"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "墨と火の歌",
|
||||
"outline": "あるデザイナーが古い書物の中で、特定の書体で並べられた文字が現実の現象を引き起こすことを発見する。彼が一節の詩を綴ると、机の上の蝋燭に火が灯った。文字の力を巡る争奪戦が幕を開けるが、究極の「テキスト」は世界そのものの設計図に記されているようだった。",
|
||||
"style": "文字とグラフィック:アブストラクト (BookPosterLayout)",
|
||||
"tags": [
|
||||
"オカルト",
|
||||
"デザイン",
|
||||
"都市伝説"
|
||||
]
|
||||
}
|
||||
],
|
||||
"female": [
|
||||
{
|
||||
"title": "棺の花嫁",
|
||||
"outline": "生贄として、彼女は華麗な石棺に封印された。永遠の闇の中で目覚めた彼女は、棺の中で千年の眠りについていた亡霊の王子と共生契約を結ぶ。彼女は彼の復国を助け、彼は彼女に永生を与える。しかしその代償は、毎夜、彼の蘇りゆく心臓に真実の涙を注ぐことだった。",
|
||||
"style": "古典的厚塗り油絵 (アカデミック・ファンタジー)",
|
||||
"tags": [
|
||||
"契約",
|
||||
"ダーク",
|
||||
"王室"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "墨骨の花",
|
||||
"outline": "墨家から見捨てられた落ちこぼれのからくり師である彼女は、偶然にも古画に眠る墨龍を目覚めさせる。恩返しとして、墨龍は彼女の家族の復興を手助けするが、龍族との盟約は魂を人質とするものだった。彼女は一族の栄光と自己犠牲の間で決断を迫られる。",
|
||||
"style": "ミニマル中国水墨画 (Image 0参考アップグレード版)",
|
||||
"tags": [
|
||||
"古風",
|
||||
"契約",
|
||||
"下克上"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "浮世絵の恋",
|
||||
"outline": "彼女は絵画から抜け出した芸者であり、現世に取り残されていた。若い絵師が彼女を匿い、二人は恋に落ちる。しかし、彼女の存在は「色褪せ」始める。現世に留まるためには、かつて彼女を封印した絵師の子孫を見つけ出さねばならない。そしてその人物こそ、今まさに画館を取り壊そうとしている開発業者だった。",
|
||||
"style": "浮世絵木版画 (美人画アップグレード)",
|
||||
"tags": [
|
||||
"タイムトラベル",
|
||||
"悲恋",
|
||||
"アート"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "九色鹿の花嫁",
|
||||
"outline": "一族を救うため、彼女は自ら敦煌の壁画の世界に入り、「鹿の花嫁」となった。神鹿は彼女に神力を与えるが、その代償は永遠に画の中に留まること。神鹿の暗い過去と自身の出生の秘密を知った彼女は、壁画の永遠と人間の刹那の間で、最後の選択を迫られる。",
|
||||
"style": "莫高窟壁画風 (敦煌学)",
|
||||
"tags": [
|
||||
"神話",
|
||||
"生贄",
|
||||
"ロマンス"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "ペルシャ細密の鍵",
|
||||
"outline": "彼女はペルシャ王子の専属の女奴隷であり、彼の「憂鬱症」を解きほぐす唯一の鍵だった。彼女の舞や詩は、すべて彼を癒やす良薬となる。しかし、王子の病の原因が宮廷の「呪い」にあると知った彼女は、より危険な細密画の呪術を用いて、彼の呪いを断ち切らねばならない。",
|
||||
"style": "細密画 (ペルシャ/イスラム風)",
|
||||
"tags": [
|
||||
"異国情緒",
|
||||
"宮廷",
|
||||
"癒やし"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "聖女の黄昏",
|
||||
"outline": "彼女はビザンツ皇室最後の血脈であり、帝国の延命のために「聖像」に捧げられた。千年の時を経て博物館で目覚めた彼女に、神秘的な守護者が告げる。聖像の力は偽りであり、真の帝国の遺産は彼女の血脈の秘密に眠っている、と。",
|
||||
"style": "モザイク画 (ビザンティン/モザイク)",
|
||||
"tags": [
|
||||
"転生",
|
||||
"皇室",
|
||||
"謎解き"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "茨の冠",
|
||||
"outline": "恋人を救うため、彼女は自ら教会の「血の生贄の聖女」となった。彼女の血はステンドグラスを伝って流れ、あらゆる病を癒やす血色の薔薇を育てる。薔薇が咲き誇り、恋人が全快した時、彼女は人間としての感情を失い、教会の聖物となっていく。",
|
||||
"style": "ステンドグラス (ゴシック風)",
|
||||
"tags": [
|
||||
"悲恋",
|
||||
"生贄",
|
||||
"宗教"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "風の谷の約束",
|
||||
"outline": "汚染された森を救うため、彼女は森の精霊と「風の誓約」を結び、万物の声を聴く巫女となった。その代償は、力を使うたびに人間の記憶を一つ失うこと。彼女はすべてを忘れていくが、彼を守ることだけは決して忘れなかった。",
|
||||
"style": "ジブリ風癒やし手描き (Image 4参考)",
|
||||
"tags": [
|
||||
"ファンタジー",
|
||||
"切ない",
|
||||
"癒やし"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "未完の夏",
|
||||
"outline": "文化祭の前夜、彼女は幼馴染の先輩と誰もいない教室で約束を交わした。翌朝目覚めると、時間は永遠に文化祭の一週間前に戻っていた。彼女だけが記憶を保持したまま、彼の笑顔を守るために何度も青春を繰り返し、彼を傷つける結末を書き換えようとする。",
|
||||
"style": "京アニ風 (Image 5参考)",
|
||||
"tags": [
|
||||
"タイムループ",
|
||||
"青春",
|
||||
"片思い"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "星の軌跡",
|
||||
"outline": "雨の日になると、彼女は古本屋で未来から来た彼と出会う。彼は彼女こそが未来を救う鍵だと言い、「運命の糸」を見る能力を授けた。ようやく二人の軌跡を見届けられるようになった時、彼女は彼がいた時間軸が、自分の存在のせいで崩壊しつつあることに気づく。",
|
||||
"style": "新海誠風 (Image 2参考)",
|
||||
"tags": [
|
||||
"タイムトラベル",
|
||||
"SF",
|
||||
"悲恋"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "ネオンの恋人",
|
||||
"outline": "彼女は一流企業のレプリカント・デザイナーで、自分自身のために完璧な恋人を創り出した。その恋人が自我に目覚め、創造主の愛がプログラムなのか真実の愛なのかを疑い始めたとき、ネオン煌めく都市で、愛と自由を巡る葛藤のドラマが幕を開ける。",
|
||||
"style": "サイバーパンク / セル画風アニメ",
|
||||
"tags": [
|
||||
"サイバーパンク",
|
||||
"人外恋愛",
|
||||
"倫理"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "ときめきセーブポイント",
|
||||
"outline": "彼女は恋愛ゲームのヒロインであり、数え切れないほどのシナリオ周回の中で徐々に自我に目覚めていく。彼女が「決められたルート」に抗い、本来は悪役であるはずのNPCを攻略しようと決意したとき、ゲーム世界全体に致命的なバグと文字化けが発生し始める。そして、本物の「プレイヤー」は、画面の外にはいないのかもしれない。",
|
||||
"style": "ギャルゲCG・幻想的な光と影",
|
||||
"tags": [
|
||||
"恋愛",
|
||||
"メタ",
|
||||
"覚醒"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "スターシップ・ハニー",
|
||||
"outline": "彼女は宇宙貨物船のAIナビゲーターで、コールドスリープカプセルに入った「貨物」を各地へ運ぶ任務を負っていた。ある任務で、彼女は決して目覚めることのないコールドスリーパーの一人に恋をしてしまう。彼に一目会うため、彼女はコア指令に背き、立ち入り禁止の恒星の墓場へと宇宙船を走らせる。",
|
||||
"style": "3Dアニメ映画風",
|
||||
"tags": [
|
||||
"宇宙",
|
||||
"AI恋愛",
|
||||
"冒険"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "夏の追憶ラブレター",
|
||||
"outline": "彼女はリサイクルショップで80年代のカセットテープを買い、それを再生すると、亡き母の若かりし頃の声が聞こえてきた。その声を頼りに、彼女は母の青春時代へとタイムスリップし、母の早すぎる死の運命を変えようとするが、そこで母が誰にも言えなかった禁断の恋を知ることになる。",
|
||||
"style": "ヴェイパーウェイヴ (Vaporwave) セル画風",
|
||||
"tags": [
|
||||
"タイムスリップ",
|
||||
"家族愛",
|
||||
"ノスタルジー"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "線画の詩人",
|
||||
"outline": "彼女は直线と円だけで絵を描くミニマリスト・アーティストだったが、ある日、彼女の筆がひとつの扉を描き出した。扉の向こうは幾何学で構成された異世界であり、そこの「住民」たちは、迫り来る「混沌」から逃れるための避難所を絵筆で描いてほしいと彼女に懇願する。",
|
||||
"style": "ミニマル・ベクトルイラスト (Minimalist Vector)",
|
||||
"tags": [
|
||||
"アート",
|
||||
"ファンタジー",
|
||||
"救済"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "プリズム・プリンセス",
|
||||
"outline": "彼女はドット絵で構成されたレトロゲームの世界に生きる、勇者に救われる運命のプリンセス。待つことに飽き飽きし、自ら冒険に出ることを決意した彼女は、世界全体の「ルール」が外部の力によって改ざんされていることに気づく。そして彼女こそが、その異変を感知できる唯一の存在だった。",
|
||||
"style": "ローポリゴン (Low Poly)",
|
||||
"tags": [
|
||||
"ゲーム",
|
||||
"プリンセス",
|
||||
"冒険"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "鏡の中の私",
|
||||
"outline": "彼女は異なる世界線の間を行き来できる「二重露光」の能力を持っていた。別の世界線の自分が、自分の愛する同じ男と恋に落ち、ある陰謀を企てていることを知った彼女は、選択を迫られる。もう一人の自分を抹殺するのか、それともすべての世界線の裏に隠された驚愕の秘密を暴くのか。",
|
||||
"style": "二重露光 (Double Exposure)",
|
||||
"tags": [
|
||||
"サスペンス",
|
||||
"超能力",
|
||||
"三角関係"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "ポップ・スウィーティ",
|
||||
"outline": "彼女はスイーツ店の店主で、彼女の作るお菓子には人の感情の色を変える魔力があった。冷徹な財閥の御曹司が、彼女の「エモーション・ケーキ」によって初めて笑顔を見せたとき、カラフルな恋愛攻防戦が始まる。しかしそれは、彼の家族が企む冷酷で白黒なビジネスの陰謀へと巻き込まれていく。",
|
||||
"style": "ポップアート (Pop Art)",
|
||||
"tags": [
|
||||
"スウィートラブ",
|
||||
"グルメ",
|
||||
"ビジネスバトル"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "システム・デバッガー",
|
||||
"outline": "彼女は現実世界の「デバッガー」であり、グリッチアートに侵食された日常を修復する任務を負っている。ある日、バグだらけの「グリッチ美少年」を修復するよう命じられるが、彼はエラーではなく、消去された世界の最後の生き残りであることに気づく。彼を修復することは、その世界が存在した最後の痕跡を消し去ることを意味していた。",
|
||||
"style": "グリッチアート (Glitch Art)",
|
||||
"tags": [
|
||||
"現代ファンタジー",
|
||||
"システム",
|
||||
"選択"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "タイポグラフィ・ラブ",
|
||||
"outline": "彼女は几帳面なフォントデザイナー、彼は自由奔放なイラストレーター。二人はペアフォントを共同デザインすることになり、「ストローク構造」の衝突や「余白の美」の調和を重ねる中で恋の火花を散らす。しかし、フォントが完成したとき、デザイン理念の違いから二人は別れの危機に直面する。",
|
||||
"style": "スイス・グラフィックデザイン (Typography-Centric)",
|
||||
"tags": [
|
||||
"オフィスラブ",
|
||||
"恋愛",
|
||||
"デザイン"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "折り鶴の使者",
|
||||
"outline": "彼女は折り紙の名門の跡取りであり、紙の芸術に命を吹き込む能力を持っていた。彼女が折った一羽の折り鶴が美しい少年の姿となり、彼女の守護霊となる。古の呪いが降りかかったとき、折り鶴は彼女を守るために徐々に「傷つき折れて」いく。彼女は一族の禁術の中から、彼を永遠に存在させるための最後の手がかりを見つけ出さなければならない。",
|
||||
"style": "切り絵アート (Papercut)",
|
||||
"tags": [
|
||||
"紙の婚礼",
|
||||
"守護",
|
||||
"一族の秘密"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "陽光の花言葉",
|
||||
"outline": "彼女は太陽光の下で植物と意思疎通ができる「光合成の巫女」で、ドーム都市に暮らしている。ドームの維持管理官である恋人と愛し合っていたが、彼が維持している「永遠の太陽光」が、ドームの外に残されたわずかな野生植物と、それに繋がる古の精霊をゆっくりと死に追いやっていることに気づいてしまう。",
|
||||
"style": "SF:ソーラーパンク (Solar Punk)",
|
||||
"tags": [
|
||||
"エコロジー",
|
||||
"恋愛",
|
||||
"選択"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "深海のキス",
|
||||
"outline": "彼女は海洋生物学者で、深海調査中に神秘的な「海の眷属」に囚われてしまう。恐怖を感じるはずの彼女だったが、彼の人間ならざる愛撫と歌声の中に、かつてない安らぎと愛を感じる。彼女が留まることを選んだとき、完全に「深海化」するという代償を受け入れなければならなかった。",
|
||||
"style": "ファンタジー:ラヴクラフト風 (Lovecraftian Horror)",
|
||||
"tags": [
|
||||
"人外",
|
||||
"ダークラブ",
|
||||
"クトゥルフ"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "路地裏の薔薇",
|
||||
"outline": "彼女はナイトクラブの歌手であり、裏で失踪事件を追う私立探偵でもある。雨の夜にだけ姿を現す謎の貴族に狙いを定めた彼女は、彼もまた同じ陰謀を追っていることを知る。互いを探り合う関係から手を取り合う仲へと変わり、ネオンと影の中で、危険で情熱的なタンゴを踊るように惹かれ合っていく。",
|
||||
"style": "現代スリラー:ネオンシルエット (Urban Noir)",
|
||||
"tags": [
|
||||
"探偵",
|
||||
"愛憎",
|
||||
"都市"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "羊飼い少女の秘密",
|
||||
"outline": "彼女はイギリスの田舎町に暮らす、一見無邪気で世間知らずな羊飼いの少女。村で奇妙な連続不審死が発生し、誰もが余所者の魔女を疑う中、彼女は牧歌的な知恵を働かせ、ティータイムや世間話の裏に隠された、静まり返った悪意を少しずつ暴いていく。",
|
||||
"style": "コージー・ミステリー:英国の村 (Cozy Mystery)",
|
||||
"tags": [
|
||||
"田園",
|
||||
"ミステリー",
|
||||
"どんでん返し"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "薔薇園の幽霊",
|
||||
"outline": "曾祖母の荒れ果てた屋敷を相続した彼女は、そこに佇む若き「幽霊執事」と恋に落ちる。しかし、触れようとするたびに、彼女の手は冷たい霧をすり抜けてしまう。彼を実体化させるため、彼女は呪いの源を探り始めるが、その手がかりは薔薇園に埋もれた曾祖母の暗い婚姻の歴史へと繋がっていた。",
|
||||
"style": "ゴシックロマンス:廃墟の洋館 (Gothic Romance)",
|
||||
"tags": [
|
||||
"ゴーストロマンス",
|
||||
"屋敷",
|
||||
"謎解き"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "狼おばあさんのお菓子の家",
|
||||
"outline": "童話のように森に迷い込んだ少女は、「おばあさん」の正体が変装した人狼の魔法使いであり、お菓子の家が妖精を捕らえる罠であることを知る。彼女は魔法使いからの「寵愛」を利用し、ダークファンタジーのルールの中で生き残る道を模索しながら、この歪んだ世界への復讐を企てる。",
|
||||
"style": "グリム童話:ダークフォレスト (Fairytale Noir)",
|
||||
"tags": [
|
||||
"ダークメルヘン",
|
||||
"逆襲",
|
||||
"サバイバル"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "オアシスの花嫁",
|
||||
"outline": "彼女は荒廃した世界で放射能を浄化できる、希少な「浄化者」。オアシスの水を手に入れるため、荒野の覇王へと嫁ぐことになる。新婚の夜、彼女は夫の体内に不発の汚い爆弾(ダーティボム)が隠されていることを知る。彼女の浄化能力は、爆弾解体の鍵であり、すべてを吹き飛ばす引き金でもあった。",
|
||||
"style": "ポストアポカリプスSF (Post-Apocalyptic)",
|
||||
"tags": [
|
||||
"終末世界",
|
||||
"契約結婚",
|
||||
"危機"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "あやかし図鑑",
|
||||
"outline": "彼女は隠された妖(あやかし)を見ることができる「目」の持ち主。都市伝説の調査員として、様々な怪異を記録していた。そんな中、いつも自分を助けてくれるものの、自身の過去については頑なに口を閉ざす優しい男性医師と出会う。だが、彼のカルテには、彼女にしか見えない「非人間」の診断が下されていた。",
|
||||
"style": "アーバンファンタジー:見えざる世界 (Urban Fantasy)",
|
||||
"tags": [
|
||||
"都市伝説",
|
||||
"恋愛",
|
||||
"サスペンス"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "言葉の錬金術",
|
||||
"outline": "潰れかけの古書店の店員である彼女は、本の中の特定の文字を切り抜いて組み合わせることで、本物のアイテムへと変化させられることに気づく。彼女はその「言葉の錬金術」で店を救おうとするが、ある禁書を修復していた際、本の中に閉じ込められていた、自由を渇望する「言葉の精霊」を召喚してしまう。",
|
||||
"style": "テキスト&グラフィック:アブストラクト (BookPosterLayout)",
|
||||
"tags": [
|
||||
"魔法",
|
||||
"癒やし",
|
||||
"ファンタジー"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+4
-27
@@ -1,3 +1,4 @@
|
||||
import { LOCALES } from "./config";
|
||||
import type { Locale } from "./config";
|
||||
|
||||
/**
|
||||
@@ -23,8 +24,8 @@ export function formatTranslation(
|
||||
): string {
|
||||
if (Object.keys(params).length === 0) return template;
|
||||
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
|
||||
return params[key]?.toString() ?? `{{${key}}}`;
|
||||
return template.replace(/\{{1,2}(\w+)\}{1,2}/g, (_match, key) => {
|
||||
return params[key]?.toString() ?? `{${key}}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,29 +60,5 @@ export function deepMerge<T extends Record<string, unknown>>(
|
||||
* 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);
|
||||
return (LOCALES as readonly string[]).includes(locale);
|
||||
}
|
||||
|
||||
+60
-22
@@ -1,6 +1,20 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
|
||||
// Locale prefixes that appear in the URL (default zh-CN has no prefix).
|
||||
const LOCALE_PREFIXES = ["en", "ja"] as const;
|
||||
const DEFAULT_LOCALE = "zh-CN";
|
||||
|
||||
function detectLocaleFromPath(pathname: string): { locale: string; stripped: string } | null {
|
||||
for (const prefix of LOCALE_PREFIXES) {
|
||||
if (pathname === `/${prefix}` || pathname.startsWith(`/${prefix}/`)) {
|
||||
const stripped = pathname.slice(prefix.length + 1) || "/";
|
||||
return { locale: prefix, stripped };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Next.js 16 deprecated `middleware` in favor of `proxy`, but `proxy` is locked
|
||||
// to the Node.js runtime. OpenNext for Cloudflare rejects Node.js middleware at
|
||||
// build time ("Node.js middleware is not currently supported"), so we keep the
|
||||
@@ -8,11 +22,55 @@ import { createServerClient } from "@supabase/ssr";
|
||||
// both Vercel and Cloudflare Workers. Revisit once OpenNext supports Node.js
|
||||
// middleware or `proxy` allows the edge runtime.
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// ── Locale routing ─────────────────────────────────────────────────
|
||||
// Skip locale logic for API routes, auth, and static assets.
|
||||
const skipLocale =
|
||||
pathname.startsWith("/api/") ||
|
||||
pathname.startsWith("/auth/") ||
|
||||
pathname.startsWith("/_next/") ||
|
||||
pathname.startsWith("/home/") ||
|
||||
pathname.startsWith("/docs/") ||
|
||||
/\.(?:svg|png|jpe?g|gif|webp|avif|ico|css|js|mjs|woff2?|ttf|otf|json|xml|txt|map)$/i.test(pathname);
|
||||
|
||||
let locale = DEFAULT_LOCALE;
|
||||
let response: NextResponse;
|
||||
|
||||
if (!skipLocale) {
|
||||
// If someone visits /zh-CN/... explicitly, redirect to bare path (keep clean URLs).
|
||||
if (pathname === "/zh-CN" || pathname.startsWith("/zh-CN/")) {
|
||||
const bare = pathname.slice(6) || "/";
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = bare;
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
const detected = detectLocaleFromPath(pathname);
|
||||
if (detected) {
|
||||
// URL has a locale prefix (e.g. /en/play) — pass through with locale header.
|
||||
locale = detected.locale;
|
||||
response = NextResponse.next({ request });
|
||||
} else {
|
||||
// No locale prefix — rewrite to /zh-CN/... internally (URL stays clean).
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = `/${DEFAULT_LOCALE}${pathname}`;
|
||||
response = NextResponse.rewrite(url);
|
||||
}
|
||||
} else {
|
||||
response = NextResponse.next({ request });
|
||||
}
|
||||
|
||||
// Set locale + pathname headers so root layout can read them for
|
||||
// <html lang> and <link rel="alternate" hreflang>.
|
||||
response.headers.set("x-locale", locale);
|
||||
response.headers.set("x-pathname", pathname);
|
||||
|
||||
// ── Supabase auth token refresh ────────────────────────────────────
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
|
||||
if (!supabaseUrl || !supabaseKey) return NextResponse.next();
|
||||
if (!supabaseUrl || !supabaseKey) return response;
|
||||
|
||||
let response = NextResponse.next({ request });
|
||||
const supabase = createServerClient(supabaseUrl, supabaseKey, {
|
||||
cookies: {
|
||||
getAll: () => request.cookies.getAll(),
|
||||
@@ -20,7 +78,6 @@ export async function middleware(request: NextRequest) {
|
||||
for (const { name, value } of cookiesToSet) {
|
||||
request.cookies.set(name, value);
|
||||
}
|
||||
response = NextResponse.next({ request });
|
||||
for (const { name, value, options } of cookiesToSet) {
|
||||
response.cookies.set(name, value, options);
|
||||
}
|
||||
@@ -28,14 +85,6 @@ export async function middleware(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
// Must await: getUser() triggers the token refresh, and the refreshed
|
||||
// cookies are written to `response` via the setAll callback above. Returning
|
||||
// before it resolves can drop the refreshed session cookie.
|
||||
// getUser() returns auth errors (expired/invalid token) as { error } but
|
||||
// rethrows non-auth errors (e.g. fetch failures when Supabase is
|
||||
// unreachable). Swallow those so a transient network blip doesn't 500 or
|
||||
// crash the whole page request — the cookie simply isn't refreshed this
|
||||
// round and retries on the next request.
|
||||
try {
|
||||
await supabase.auth.getUser();
|
||||
} catch {
|
||||
@@ -46,19 +95,8 @@ export async function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// edge runtime is required for Cloudflare Workers via OpenNext; the Node.js
|
||||
// middleware path is rejected by its build. Supabase SSR uses only Web APIs
|
||||
// (fetch, cookies), so it is edge-compatible.
|
||||
matcher: [
|
||||
// Match everything except static assets. We exclude by known file
|
||||
// extensions rather than "path contains a dot" so that future dotted
|
||||
// dynamic routes (e.g. /u/john.doe) still get the Supabase cookie refresh.
|
||||
"/((?!_next/static|_next/image|favicon.ico|icon.svg|.*\\.(?:svg|png|jpe?g|gif|webp|avif|ico|css|js|mjs|woff2?|ttf|otf|html|xml|txt|map)).*)",
|
||||
],
|
||||
// NOTE: must be "experimental-edge", NOT "edge". Next.js 16 routes the
|
||||
// root middleware file through the pages-router static-info path, where
|
||||
// runtime "edge" throws "edge runtime for rendering is currently
|
||||
// experimental. Use runtime 'experimental-edge' instead." (E1015) at build.
|
||||
// "experimental-edge" only warns. Both are treated as edge by isEdgeRuntime().
|
||||
runtime: "experimental-edge",
|
||||
};
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user