feat(i18n): Integrate next-intl, localize core UI, add regional locales and zh-TW Discover sources

**Overview**
- Integrates next-intl (App Router, no i18n routing) with cookie-based locale and Accept-Language fallback.
- Adds message bundles and regional variants; sets en-US as the default.

**Key changes**
- i18n foundation
  - Adds request-scoped config to load messages per locale and injects NextIntlClientProvider in [layout.tsx]
  - Adds/updates messages for: en-US, en-GB, zh-TW, zh-HK, zh-CN, ja, ko, fr-FR, fr-CA, de.
Centralizes LOCALES, LOCALE_LABELS, and DEFAULT_LOCALE in [locales.ts]
  - Adds LocaleSwitcher (cookie-based) and [LocaleBootstrap]

- Pages and components
  - Localizes Sidebar, Home (including metadata/manifest), Settings, Discover, Library.
  - Localizes common components: MessageInput, Attach, Focus, Optimization, MessageBox, MessageSources, SearchImages, SearchVideos, EmptyChat, NewsArticleWidget, WeatherWidget.

- APIs
  - Weather API returns localized condition strings server-side.

- UX and quality
  - Converts all remaining <img> to Next Image.
  - Updates browserslist/caniuse DB to silence warnings.
  - Security: Settings API Key inputs are now password fields and placeholders were removed.
This commit is contained in:
wei840222 2025-08-16 12:27:18 +08:00
parent 0dc17286b9
commit 9a772d6abe
56 changed files with 3673 additions and 365 deletions

40
src/i18n/locales.ts Normal file
View file

@ -0,0 +1,40 @@
export const LOCALES = [
'en-US',
'en-GB',
'zh-TW',
'zh-HK',
'zh-CN',
'ja',
'ko',
'fr-FR',
'fr-CA',
'de',
] as const;
export type AppLocale = (typeof LOCALES)[number];
// Default locale for fallbacks
export const DEFAULT_LOCALE: AppLocale = 'en-US';
// UI labels for language options
export const LOCALE_LABELS: Record<AppLocale, string> = {
'en-US': 'English (US)',
'en-GB': 'English (UK)',
'zh-TW': '繁體中文',
'zh-HK': '繁體中文(香港)',
'zh-CN': '简体中文',
ja: '日本語',
ko: '한국어',
'fr-FR': 'Français (France)',
'fr-CA': 'Français (Canada)',
de: 'Deutsch',
};
// Human-readable language name for prompt prefix
export function getPromptLanguageName(loc: string): string {
const l = (loc || '').toLowerCase();
const match = (
Object.keys(LOCALE_LABELS) as Array<keyof typeof LOCALE_LABELS>
).find((k) => k.toLowerCase() === l);
if (match) return LOCALE_LABELS[match];
return LOCALE_LABELS[DEFAULT_LOCALE];
}

115
src/i18n/request.ts Normal file
View file

@ -0,0 +1,115 @@
import { cookies, headers } from 'next/headers';
import { getRequestConfig } from 'next-intl/server';
import { LOCALES, DEFAULT_LOCALE, type AppLocale } from './locales';
export default getRequestConfig(async () => {
const cookieStore = await cookies();
const rawCookieLocale = cookieStore.get('locale')?.value;
// Helper: parse Accept-Language and pick best supported locale
function resolveFromAcceptLanguage(al: string | null | undefined): AppLocale {
const supported = new Set<string>(LOCALES as readonly string[]);
const raw = (al || '').toLowerCase();
if (!raw) return DEFAULT_LOCALE;
type Candidate = { tag: string; q: number };
const candidates: Candidate[] = raw
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const [tagPart, ...params] = part.split(';');
const tag = tagPart.trim();
let q = 1;
for (const p of params) {
const m = p.trim().match(/^q=([0-9.]+)$/);
if (m) {
const v = parseFloat(m[1]);
if (!Number.isNaN(v)) q = v;
}
}
return { tag, q } as Candidate;
})
.sort((a, b) => b.q - a.q);
// Try in order: exact match -> base language match -> custom mapping
for (const { tag } of candidates) {
// exact match against supported
const exact = Array.from(supported).find((s) => s.toLowerCase() === tag);
if (exact) return exact as AppLocale;
// base language match (e.g., en-US -> en-GB/en-US: prefer en-US if available)
const base = tag.split('-')[0];
const englishVariants = Array.from(supported).filter((s) =>
s.toLowerCase().startsWith('en-'),
) as AppLocale[];
if (base === 'en' && englishVariants.length > 0) {
// prefer en-US as default English
const enUS = englishVariants.find((e) => e.toLowerCase() === 'en-us');
return (enUS || englishVariants[0]) as AppLocale;
}
const baseMatch = Array.from(supported).find(
(s) => s.split('-')[0].toLowerCase() === base,
);
if (baseMatch) return baseMatch as AppLocale;
// custom mapping for Chinese:
// - zh-HK -> zh-HK
// - zh-TW -> zh-TW
// - zh-CN, zh-SG -> zh-CN
if (tag.startsWith('zh')) {
if (/^zh-(hk)/i.test(tag)) return 'zh-HK';
if (/^zh-(tw)/i.test(tag)) return 'zh-TW';
if (/^zh-(cn|sg)/i.test(tag)) return 'zh-CN';
// default Chinese fallback: zh-TW
return 'zh-TW';
}
}
return DEFAULT_LOCALE;
}
// Normalize any incoming locale (including legacy cookies like 'en' or 'fr')
function normalizeToSupported(loc?: string | null): AppLocale | null {
const val = (loc || '').trim();
if (!val) return null;
const lower = val.toLowerCase();
// exact case-insensitive match against supported
const exact = (LOCALES as readonly string[]).find(
(s) => s.toLowerCase() === lower,
);
if (exact) return exact as AppLocale;
// map base tags to preferred regional variants
const base = lower.split('-')[0];
if (base === 'en') return 'en-US';
if (base === 'fr') return 'fr-FR';
if (base === 'zh') {
if (/^zh-(hk)/i.test(lower)) return 'zh-HK';
if (/^zh-(tw)/i.test(lower)) return 'zh-TW';
if (/^zh-(cn|sg)/i.test(lower)) return 'zh-CN';
// default Chinese fallback
return 'zh-TW';
}
// try base language match generically
const baseMatch = (LOCALES as readonly string[]).find(
(s) => s.split('-')[0].toLowerCase() === base,
);
return (baseMatch as AppLocale) || null;
}
// Prefer normalized cookie if present and valid; otherwise use Accept-Language
let locale: AppLocale;
const normalizedCookie = normalizeToSupported(rawCookieLocale);
if (normalizedCookie) {
locale = normalizedCookie;
} else {
const hdrs = await headers();
const acceptLanguage = hdrs.get('accept-language');
locale = resolveFromAcceptLanguage(acceptLanguage);
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});