feat: add localization and site settings

This commit is contained in:
rucky
2026-05-12 09:58:25 +08:00
parent 9dc6c0dcce
commit fa7aedb8e7
67 changed files with 5221 additions and 888 deletions

View File

@@ -0,0 +1,86 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import {
DEFAULT_LOCALE,
LOCALE_COOKIE,
messages,
type Locale,
type Messages,
} from "./messages";
type Section = keyof Messages;
type Key<S extends Section> = keyof Messages[S];
interface LocaleContextValue {
locale: Locale;
setLocale: (next: Locale) => void;
t: <S extends Section>(
section: S,
key: Key<S>,
vars?: Record<string, string | number>
) => string;
}
const LocaleContext = createContext<LocaleContextValue | null>(null);
function interpolate(template: string, vars?: Record<string, string | number>) {
if (!vars) return template;
return template.replace(/\{(\w+)\}/g, (_, k) =>
vars[k] !== undefined ? String(vars[k]) : `{${k}}`
);
}
export function LocaleProvider({
initialLocale,
children,
}: {
initialLocale: Locale;
children: ReactNode;
}) {
const [locale, setLocaleState] = useState<Locale>(initialLocale);
const setLocale = useCallback((next: Locale) => {
setLocaleState(next);
if (typeof document !== "undefined") {
// 1 year, root path
document.cookie = `${LOCALE_COOKIE}=${next}; path=/; max-age=${
60 * 60 * 24 * 365
}; samesite=lax`;
document.documentElement.lang = next === "en" ? "en" : "zh-CN";
}
}, []);
const value = useMemo<LocaleContextValue>(() => {
const dict = messages[locale] ?? messages[DEFAULT_LOCALE];
return {
locale,
setLocale,
t: (section, key, vars) => {
const sectionDict = dict[section] as Record<string, string>;
const tpl = sectionDict?.[key as string];
if (typeof tpl !== "string") return `${String(section)}.${String(key)}`;
return interpolate(tpl, vars);
},
};
}, [locale, setLocale]);
return (
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
);
}
export function useLocale() {
const ctx = useContext(LocaleContext);
if (!ctx) {
throw new Error("useLocale must be used inside <LocaleProvider>");
}
return ctx;
}

View File

@@ -0,0 +1,68 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import {
WOW_COOKIE,
WOW_VERSIONS,
type WowVersion,
} from "@/lib/wow-versions";
interface WowVersionContextValue {
wowVersion: WowVersion;
setWowVersion: (next: WowVersion) => void;
versions: readonly WowVersion[];
}
const WowVersionContext = createContext<WowVersionContextValue | null>(null);
export function WowVersionProvider({
initial,
children,
}: {
initial: WowVersion;
children: ReactNode;
}) {
const [wowVersion, setLocal] = useState<WowVersion>(initial);
const setWowVersion = useCallback((next: WowVersion) => {
setLocal(next);
if (typeof document !== "undefined") {
document.cookie = `${WOW_COOKIE}=${next}; path=/; max-age=${
60 * 60 * 24 * 365
}; samesite=lax`;
// Server components rely on the cookie — reload so the SSR'd lists refresh.
// Use a microtask so React state has a chance to commit first.
setTimeout(() => {
if (typeof window !== "undefined") {
window.location.reload();
}
}, 0);
}
}, []);
const value = useMemo<WowVersionContextValue>(
() => ({ wowVersion, setWowVersion, versions: WOW_VERSIONS }),
[wowVersion, setWowVersion]
);
return (
<WowVersionContext.Provider value={value}>
{children}
</WowVersionContext.Provider>
);
}
export function useWowVersion() {
const ctx = useContext(WowVersionContext);
if (!ctx) {
throw new Error("useWowVersion must be used inside <WowVersionProvider>");
}
return ctx;
}

30
src/i18n/getLocale.ts Normal file
View File

@@ -0,0 +1,30 @@
import { cookies, headers } from "next/headers";
import { DEFAULT_LOCALE, LOCALES, LOCALE_COOKIE, type Locale } from "./messages";
function isLocale(v: string | undefined | null): v is Locale {
return !!v && (LOCALES as string[]).includes(v);
}
/**
* Resolve the current locale on the server.
* 1. Explicit cookie
* 2. Accept-Language header (en* → en, otherwise zh)
* 3. Default
*/
export async function getServerLocale(): Promise<Locale> {
const cookieStore = await cookies();
const cookieLocale = cookieStore.get(LOCALE_COOKIE)?.value;
if (isLocale(cookieLocale)) return cookieLocale;
try {
const h = await headers();
const accept = h.get("accept-language") || "";
const first = accept.split(",")[0]?.trim().toLowerCase() || "";
if (first.startsWith("en")) return "en";
if (first.startsWith("zh")) return "zh";
} catch {
// headers() can throw outside request scope
}
return DEFAULT_LOCALE;
}

201
src/i18n/messages.ts Normal file
View File

@@ -0,0 +1,201 @@
export type Locale = "zh" | "en";
export const LOCALES: Locale[] = ["zh", "en"];
export const DEFAULT_LOCALE: Locale = "zh";
export const LOCALE_COOKIE = "locale";
export const messages = {
zh: {
common: {
switchToEn: "EN",
switchToZh: "中",
language: "语言",
downloads: "次下载",
version: "版本",
},
nav: {
addons: "插件列表",
articles: "公告",
changelog: "更新日志",
openMenu: "打开菜单",
closeMenu: "关闭菜单",
home: "首页",
},
hero: {
tagline: "Turtle WoW 一站式插件管理平台",
download: "下载 Nanami 启动器",
goToSlide: "前往第 {n} 张",
},
footer: {
tagline1: "Turtle WoW 一站式插件管理平台。",
tagline2: "轻松安装、更新、管理你的游戏插件。",
quickNav: "快速导航",
addons: "插件列表",
articlesFull: "公告文章",
changelog: "更新日志",
getStarted: "开始使用",
download: "下载 Nanami 启动器",
platform: "Windows 10 / 11 · 免费使用",
copyright: "© {year} Nanami. All rights reserved.",
madeFor: "Made for Turtle WoW community",
},
articles: {
title: "公告与文章",
subtitle: "最新动态与更新公告",
empty: "暂无文章",
back: "返回文章列表",
},
addons: {
title: "插件列表",
subtitle: "浏览和下载 World of Warcraft 插件",
all: "全部",
downloadsCount: "次下载",
emptyTitle: "暂无插件",
emptySubtitle: "稍后再来查看吧",
notFoundTitle: "没有找到“{q}”相关的插件",
notFoundSubtitle: "尝试更换关键词搜索",
back: "返回插件列表",
introduction: "介绍",
screenshots: "截图",
releaseHistory: "版本历史",
releaseCount: "共 {n} 个版本",
noReleases: "暂无版本发布",
latestTag: "最新",
},
category: {
general: "通用",
gameplay: "游戏玩法",
ui: "界面增强",
combat: "战斗",
raid: "团队副本",
pvp: "PvP",
tradeskill: "专业技能",
utility: "实用工具",
},
home: {
gameplayShowcase: "实机演示",
featureDeepTitle: "深度适配",
featureDeepDesc: "专为乌龟服 1.18.0 打造,兼容自定义内容与新种族,稳定流畅",
featureInstallTitle: "一键安装管理",
featureInstallDesc: "通过 Nanami 启动器自动安装、更新,告别手动拖拽文件夹",
featureAITitle: "内置 AI 翻译",
featureAIDesc: "自带智能翻译引擎,轻松畅玩英文服务器,语言不再是障碍",
latestArticles: "最新公告",
viewMore: "查看更多",
hotAddons: "热门插件",
viewAll: "查看全部",
},
shutdown: {
eyebrow: "In Memoriam · Turtle WoW",
ended: "旅程已至终点,感谢一路同行。",
announceEyebrow: "Launcher Announcement",
announceTitle: "Nanami 启动器·Nanami 服特殊版即将发布",
announceDesc:
"原版「Nanami 启动器」将更名为「乌龟服亚服启动器」,继续为亚服玩家提供服务;同时,面向 Nanami 服的特殊版即将上线,专为新服特性深度适配。敬请期待。",
unitDay: "天",
unitHour: "时",
unitMinute: "分",
unitSecond: "秒",
ariaLabel: "服务器关闭倒计时",
},
},
en: {
common: {
switchToEn: "EN",
switchToZh: "中",
language: "Language",
downloads: " downloads",
version: "Version",
},
nav: {
addons: "Addons",
articles: "News",
changelog: "Changelog",
openMenu: "Open menu",
closeMenu: "Close menu",
home: "Home",
},
hero: {
tagline: "All-in-one Addon Platform for Turtle WoW",
download: "Download Nanami Launcher",
goToSlide: "Go to slide {n}",
},
footer: {
tagline1: "All-in-one addon platform for Turtle WoW.",
tagline2: "Install, update, and manage your game addons effortlessly.",
quickNav: "Quick Links",
addons: "Addons",
articlesFull: "Announcements",
changelog: "Changelog",
getStarted: "Get Started",
download: "Download Nanami Launcher",
platform: "Windows 10 / 11 · Free",
copyright: "© {year} Nanami. All rights reserved.",
madeFor: "Made for the Turtle WoW community",
},
articles: {
title: "News & Announcements",
subtitle: "Latest updates and release notes",
empty: "No articles yet",
back: "Back to articles",
},
addons: {
title: "Addons",
subtitle: "Browse and download World of Warcraft addons",
all: "All",
downloadsCount: " downloads",
emptyTitle: "No addons yet",
emptySubtitle: "Check back later",
notFoundTitle: "No addons matched “{q}”",
notFoundSubtitle: "Try a different keyword",
back: "Back to addons",
introduction: "About",
screenshots: "Screenshots",
releaseHistory: "Release history",
releaseCount: "{n} release(s)",
noReleases: "No releases yet",
latestTag: "Latest",
},
category: {
general: "General",
gameplay: "Gameplay",
ui: "Interface",
combat: "Combat",
raid: "Raid & Dungeon",
pvp: "PvP",
tradeskill: "Trade Skills",
utility: "Utility",
},
home: {
gameplayShowcase: "Gameplay Showcase",
featureDeepTitle: "Deeply Tailored",
featureDeepDesc:
"Built for Turtle WoW 1.18.0 — fully compatible with custom content and new races. Stable and smooth.",
featureInstallTitle: "One-click Install",
featureInstallDesc:
"Auto-install and update via the Nanami Launcher. No more dragging folders by hand.",
featureAITitle: "Built-in AI Translation",
featureAIDesc:
"Smart translation engine built in — enjoy English servers without a language barrier.",
latestArticles: "Latest News",
viewMore: "View more",
hotAddons: "Featured Addons",
viewAll: "View all",
},
shutdown: {
eyebrow: "In Memoriam · Turtle WoW",
ended: "The journey ends here. Thank you for walking it with us.",
announceEyebrow: "Launcher Announcement",
announceTitle: "Nanami Launcher · Nanami-Server edition coming soon",
announceDesc:
"The original Nanami Launcher will be renamed to the Turtle WoW Asia Launcher and continue serving Asia players. A new Nanami-Server edition, deeply tailored for the new server, is launching soon. Stay tuned.",
unitDay: "Days",
unitHour: "Hours",
unitMinute: "Mins",
unitSecond: "Secs",
ariaLabel: "Server shutdown countdown",
},
},
} as const;
export type Messages = typeof messages.zh;