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:
parent
0dc17286b9
commit
9a772d6abe
56 changed files with 3673 additions and 365 deletions
40
src/i18n/locales.ts
Normal file
40
src/i18n/locales.ts
Normal 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
115
src/i18n/request.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue