mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 16:16:37 +08:00
feat(frontend): add next-intl bilingual i18n for core paths
Wire next-intl without locale routes, add zh-CN/en catalogs, language switcher, and migrate layout/auth/settings UI copy. Document i18n rules in AGENTS.md and keep static export builds working.
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
defaultLocale,
|
||||
isAppLocale,
|
||||
localeCookieName,
|
||||
normalizeLocale,
|
||||
type AppLocale,
|
||||
} from './config';
|
||||
|
||||
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; // 1 year
|
||||
|
||||
function readCookie(name: string): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const match = document.cookie
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(`${name}=`));
|
||||
if (!match) return null;
|
||||
return decodeURIComponent(match.slice(name.length + 1));
|
||||
}
|
||||
|
||||
export function getClientLocalePreference(): AppLocale | null {
|
||||
const raw = readCookie(localeCookieName);
|
||||
if (isAppLocale(raw)) return raw;
|
||||
if (raw) return normalizeLocale(raw);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function detectBrowserLocale(): AppLocale {
|
||||
if (typeof navigator === 'undefined') return defaultLocale;
|
||||
const languages =
|
||||
navigator.languages?.length > 0
|
||||
? navigator.languages
|
||||
: [navigator.language];
|
||||
for (const lang of languages) {
|
||||
const lower = (lang ?? '').toLowerCase();
|
||||
if (lower.startsWith('zh') || lower.startsWith('en')) {
|
||||
return normalizeLocale(lang);
|
||||
}
|
||||
}
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
export function resolveClientLocale(): AppLocale {
|
||||
return getClientLocalePreference() ?? detectBrowserLocale();
|
||||
}
|
||||
|
||||
export function setLocaleCookie(locale: AppLocale): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const secure =
|
||||
typeof window !== 'undefined' && window.location.protocol === 'https:'
|
||||
? '; Secure'
|
||||
: '';
|
||||
document.cookie = `${localeCookieName}=${encodeURIComponent(locale)}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax${secure}`;
|
||||
try {
|
||||
localStorage.setItem(localeCookieName, locale);
|
||||
} catch {
|
||||
// ignore quota / private mode
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist locale and reload so server + client messages stay in sync. */
|
||||
export function switchLocale(locale: AppLocale): void {
|
||||
setLocaleCookie(locale);
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export const locales = ['zh-CN', 'en'] as const;
|
||||
|
||||
export type AppLocale = (typeof locales)[number];
|
||||
|
||||
export const defaultLocale: AppLocale = 'zh-CN';
|
||||
|
||||
export const localeCookieName = 'NEXT_LOCALE';
|
||||
|
||||
export const localeLabels: Record<AppLocale, string> = {
|
||||
'zh-CN': '中文',
|
||||
en: 'English',
|
||||
};
|
||||
|
||||
export function isAppLocale(
|
||||
value: string | undefined | null,
|
||||
): value is AppLocale {
|
||||
return value === 'zh-CN' || value === 'en';
|
||||
}
|
||||
|
||||
/** Normalize BCP 47 / browser tags into a supported app locale. */
|
||||
export function normalizeLocale(input?: string | null): AppLocale {
|
||||
if (!input) return defaultLocale;
|
||||
const tag = input.trim().replaceAll('_', '-');
|
||||
if (!tag) return defaultLocale;
|
||||
|
||||
const lower = tag.toLowerCase();
|
||||
if (lower === 'zh-cn' || lower.startsWith('zh-hans') || lower === 'zh') {
|
||||
return 'zh-CN';
|
||||
}
|
||||
if (lower.startsWith('zh')) {
|
||||
// zh-TW / zh-Hant etc. map to zh-CN until a dedicated locale exists
|
||||
return 'zh-CN';
|
||||
}
|
||||
if (lower === 'en' || lower.startsWith('en-')) {
|
||||
return 'en';
|
||||
}
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
/** Pick the best locale from an Accept-Language style list. */
|
||||
export function pickLocaleFromAcceptLanguage(
|
||||
header?: string | null,
|
||||
): AppLocale {
|
||||
if (!header) return defaultLocale;
|
||||
|
||||
const candidates = header
|
||||
.split(',')
|
||||
.map((part) => {
|
||||
const [tag, ...params] = part.trim().split(';');
|
||||
const qParam = params.find((p) => p.trim().startsWith('q='));
|
||||
const q = qParam ? Number.parseFloat(qParam.split('=')[1] ?? '1') : 1;
|
||||
return { tag: tag?.trim() ?? '', q: Number.isFinite(q) ? q : 0 };
|
||||
})
|
||||
.filter((c) => c.tag)
|
||||
.sort((a, b) => b.q - a.q);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeLocale(candidate.tag);
|
||||
// Only accept if the raw tag roughly matches a supported family
|
||||
const lower = candidate.tag.toLowerCase();
|
||||
if (lower.startsWith('zh') || lower.startsWith('en')) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultLocale;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { AppLocale } from './config';
|
||||
import { defaultLocale } from './config';
|
||||
|
||||
const localeToIntl: Record<AppLocale, string> = {
|
||||
'zh-CN': 'zh-CN',
|
||||
en: 'en-US',
|
||||
};
|
||||
|
||||
export function formatDateTime(
|
||||
dateStr: string | Date,
|
||||
locale: AppLocale = defaultLocale,
|
||||
timeZone = 'Asia/Shanghai',
|
||||
): string {
|
||||
try {
|
||||
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
|
||||
return new Intl.DateTimeFormat(localeToIntl[locale] ?? locale, {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
} catch {
|
||||
return String(dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatNumber(
|
||||
value: number,
|
||||
locale: AppLocale = defaultLocale,
|
||||
): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(localeToIntl[locale] ?? locale).format(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import {
|
||||
defaultLocale,
|
||||
isAppLocale,
|
||||
localeCookieName,
|
||||
normalizeLocale,
|
||||
pickLocaleFromAcceptLanguage,
|
||||
type AppLocale,
|
||||
} from './config';
|
||||
|
||||
async function resolveRequestLocale(): Promise<AppLocale> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const cookieLocale = cookieStore.get(localeCookieName)?.value;
|
||||
if (isAppLocale(cookieLocale)) {
|
||||
return cookieLocale;
|
||||
}
|
||||
if (cookieLocale) {
|
||||
return normalizeLocale(cookieLocale);
|
||||
}
|
||||
} catch {
|
||||
// Static export / non-request contexts cannot read cookies.
|
||||
}
|
||||
|
||||
try {
|
||||
const headerStore = await headers();
|
||||
const acceptLanguage = headerStore.get('accept-language');
|
||||
return pickLocaleFromAcceptLanguage(acceptLanguage);
|
||||
} catch {
|
||||
// Static export build time.
|
||||
}
|
||||
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const locale = await resolveRequestLocale();
|
||||
const messages = (await import(`../messages/${locale}.json`)).default;
|
||||
return {
|
||||
locale,
|
||||
messages,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user