feat: add localization and site settings
This commit is contained in:
86
src/i18n/LocaleProvider.tsx
Normal file
86
src/i18n/LocaleProvider.tsx
Normal 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;
|
||||
}
|
||||
68
src/i18n/WowVersionProvider.tsx
Normal file
68
src/i18n/WowVersionProvider.tsx
Normal 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
30
src/i18n/getLocale.ts
Normal 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
201
src/i18n/messages.ts
Normal 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;
|
||||
Reference in New Issue
Block a user