mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-09-23 16:01:15 +00:00
feat(i18n): Enable instant client-side language switching
Refactored i18n implementation to support seamless language switching without page reload by pre-loading all translations and using locale-aware navigation helpers. Changes: - Created centralized routing config (i18n/routing.ts) with localePrefix: 'never' - Added type-safe navigation helpers (i18n/navigation.ts) - Implemented custom IntlProvider with pre-loaded EN/FR translations - Updated all pages to use new useRouter from i18n/navigation - Added language switcher to appearance settings - Removed URL-based locale routing (no more /en/ or /fr/ prefixes) - Language preference persisted via Zustand localStorage Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.5
parent
4032692700
commit
a4e2fcf81b
@@ -4,7 +4,7 @@ import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertCircle, RefreshCw, Home } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
|
||||
/**
|
||||
* Route-level error boundary for locale pages.
|
||||
@@ -18,7 +18,6 @@ export default function LocaleError({
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations("errors");
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,7 +37,7 @@ export default function LocaleError({
|
||||
{t("page_error_description")}
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button variant="outline" onClick={() => router.push(`/${params.locale}`)}>
|
||||
<Button variant="outline" onClick={() => router.push('/')}>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
{t("go_home")}
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { notFound } from "next/navigation";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { IntlProvider } from "@/components/providers/intl-provider";
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { locales } from "@/i18n/request";
|
||||
import { locales } from "@/i18n/routing";
|
||||
import "../globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -66,11 +66,11 @@ export default async function LocaleLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
</IntlProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -11,7 +11,6 @@ import { Mail, AlertCircle, Loader2, X } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const t = useTranslations("login");
|
||||
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const { appName, jmapServerUrl: serverUrl, isLoading: configLoading, error: configError } = useConfig();
|
||||
@@ -53,9 +52,9 @@ export default function LoginPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push(`/${params.locale}`);
|
||||
router.push('/');
|
||||
}
|
||||
}, [isAuthenticated, router, params.locale]);
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
useEffect(() => {
|
||||
clearError();
|
||||
@@ -229,7 +228,7 @@ export default function LoginPage() {
|
||||
|
||||
if (success) {
|
||||
saveUsername(formData.username);
|
||||
router.push(`/${params.locale}`);
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef, useMemo } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { EmailList } from "@/components/email/email-list";
|
||||
@@ -30,7 +30,6 @@ import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const t = useTranslations();
|
||||
const [showComposer, setShowComposer] = useState(false);
|
||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||
@@ -235,9 +234,9 @@ export default function Home() {
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
router.push(`/${params.locale}/login`);
|
||||
router.push('/login');
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, router, params.locale]);
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, router]);
|
||||
|
||||
// Load mailboxes and emails when authenticated (only if not already loaded)
|
||||
useEffect(() => {
|
||||
@@ -504,7 +503,7 @@ export default function Home() {
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push(`/${params.locale}/login`);
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ArrowLeft, Settings as SettingsIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -15,7 +15,6 @@ type Tab = 'appearance' | 'email' | 'account' | 'advanced';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const t = useTranslations('settings');
|
||||
const [activeTab, setActiveTab] = useState<Tab>('appearance');
|
||||
|
||||
@@ -35,7 +34,7 @@ export default function SettingsPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/${params.locale}`)}
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -226,7 +226,6 @@ export function Sidebar({
|
||||
useEffect(() => {
|
||||
setSearchQuery(activeSearchQuery);
|
||||
}, [activeSearchQuery]);
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
// Load expanded folders from localStorage on mount
|
||||
@@ -437,7 +436,7 @@ export function Sidebar({
|
||||
<div className="border-t border-border mt-2 pt-2">
|
||||
{/* Settings */}
|
||||
<button
|
||||
onClick={() => router.push(`/${params.locale}/settings`)}
|
||||
onClick={() => router.push('/settings')}
|
||||
className="w-full px-4 py-2 flex items-center justify-between hover:bg-muted transition-colors text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import enMessages from '@/locales/en/common.json';
|
||||
import frMessages from '@/locales/fr/common.json';
|
||||
|
||||
// Pre-loaded translations (loaded at build time, not runtime)
|
||||
const ALL_MESSAGES = {
|
||||
en: enMessages,
|
||||
fr: frMessages,
|
||||
};
|
||||
|
||||
interface IntlProviderProps {
|
||||
locale: string;
|
||||
messages: any;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function IntlProvider({ locale: initialLocale, children }: IntlProviderProps) {
|
||||
const currentLocale = useLocaleStore((state) => state.locale);
|
||||
const setLocale = useLocaleStore((state) => state.setLocale);
|
||||
const [activeLocale, setActiveLocale] = useState(currentLocale || initialLocale);
|
||||
|
||||
// Sync initial locale with store on first mount only
|
||||
useEffect(() => {
|
||||
if (!currentLocale) {
|
||||
setLocale(initialLocale);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Switch locale immediately when store changes
|
||||
useEffect(() => {
|
||||
if (currentLocale) {
|
||||
setActiveLocale(currentLocale);
|
||||
}
|
||||
}, [currentLocale]);
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider
|
||||
locale={activeLocale}
|
||||
messages={ALL_MESSAGES[activeLocale as keyof typeof ALL_MESSAGES]}
|
||||
>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
|
||||
export function AppearanceSettings() {
|
||||
@@ -25,6 +26,11 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Language */}
|
||||
<SettingItem label={t('language.label')} description={t('language.description')}>
|
||||
<LanguageSwitcher />
|
||||
</SettingItem>
|
||||
|
||||
{/* Font Size */}
|
||||
<SettingItem label={t('font_size.label')} description={t('font_size.description')}>
|
||||
<RadioGroup
|
||||
|
||||
@@ -1,46 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { locales } from '@/i18n/request';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
|
||||
export function LanguageSwitcher({ className }: { className?: string }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
const currentLocale = useLocale();
|
||||
const t = useTranslations('language');
|
||||
const currentLocale = params.locale as string;
|
||||
const setLocale = useLocaleStore((state) => state.setLocale);
|
||||
|
||||
const handleLanguageChange = (newLocale: string) => {
|
||||
// Get the path without the locale prefix
|
||||
const pathWithoutLocale = pathname.replace(`/${currentLocale}`, '');
|
||||
if (newLocale === currentLocale) return;
|
||||
|
||||
// Navigate to the same page with the new locale
|
||||
router.push(`/${newLocale}${pathWithoutLocale}`);
|
||||
// Update locale in store (persisted to localStorage via Zustand)
|
||||
// IntlProvider handles the translation switch
|
||||
setLocale(newLocale);
|
||||
};
|
||||
|
||||
const languages = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1 p-1 bg-muted rounded-lg", className)}>
|
||||
{locales.map((locale) => (
|
||||
<div
|
||||
className={cn("flex gap-2", className)}
|
||||
role="radiogroup"
|
||||
aria-label={t('select_language')}
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={locale}
|
||||
onClick={() => handleLanguageChange(locale)}
|
||||
key={lang.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={currentLocale === lang.value}
|
||||
aria-label={t(lang.value === 'en' ? 'switch_to_english' : 'switch_to_french')}
|
||||
onClick={() => handleLanguageChange(lang.value)}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded transition-all text-xs",
|
||||
"text-foreground",
|
||||
currentLocale === locale
|
||||
? "bg-background shadow-sm font-medium"
|
||||
: "hover:bg-accent/50"
|
||||
"px-3 py-1.5 text-xs rounded transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
currentLocale === lang.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted hover:bg-accent text-foreground"
|
||||
)}
|
||||
title={t(locale === 'en' ? 'english' : 'french')}
|
||||
>
|
||||
{locale === 'en' ? '🇬🇧' : '🇫🇷'}
|
||||
<span className="hidden sm:inline">
|
||||
{locale.toUpperCase()}
|
||||
</span>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNavigation } from 'next-intl/navigation';
|
||||
import { routing } from './routing';
|
||||
|
||||
export const { Link, redirect, usePathname, useRouter } = createNavigation(routing);
|
||||
+4
-9
@@ -1,16 +1,11 @@
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
|
||||
export const locales = ['en', 'fr'] as const;
|
||||
export type Locale = (typeof locales)[number];
|
||||
export const defaultLocale: Locale = 'en';
|
||||
import { routing, type Locale } from './routing';
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
// Get the locale from the request or use default
|
||||
let locale = await requestLocale || defaultLocale;
|
||||
let locale = await requestLocale;
|
||||
|
||||
// Validate that the incoming `locale` parameter is valid
|
||||
if (!(locales as readonly string[]).includes(locale)) {
|
||||
locale = defaultLocale;
|
||||
if (!locale || !routing.locales.includes(locale as Locale)) {
|
||||
locale = routing.defaultLocale;
|
||||
}
|
||||
|
||||
// Use static imports for better compatibility
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineRouting } from 'next-intl/routing';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['en', 'fr'],
|
||||
defaultLocale: 'en',
|
||||
localePrefix: 'never'
|
||||
});
|
||||
|
||||
export const locales = routing.locales;
|
||||
export const defaultLocale = routing.defaultLocale;
|
||||
export type Locale = (typeof locales)[number];
|
||||
+12
-1
@@ -35,6 +35,9 @@
|
||||
"dark": "Dark mode",
|
||||
"system": "System theme"
|
||||
},
|
||||
"language": {
|
||||
"title": "Language"
|
||||
},
|
||||
"mailboxes": {
|
||||
"inbox": "Inbox",
|
||||
"sent": "Sent",
|
||||
@@ -216,7 +219,11 @@
|
||||
"language": {
|
||||
"title": "Language",
|
||||
"english": "English",
|
||||
"french": "Français"
|
||||
"french": "Français",
|
||||
"select_language": "Select language",
|
||||
"switch_to_english": "Switch to English",
|
||||
"switch_to_french": "Switch to French",
|
||||
"switching": "Changing language..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -244,6 +251,10 @@
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
},
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"description": "Choose your preferred language"
|
||||
},
|
||||
"font_size": {
|
||||
"label": "Font Size",
|
||||
"description": "Adjust text size for better readability",
|
||||
|
||||
+12
-1
@@ -35,6 +35,9 @@
|
||||
"dark": "Mode sombre",
|
||||
"system": "Thème système"
|
||||
},
|
||||
"language": {
|
||||
"title": "Langue"
|
||||
},
|
||||
"mailboxes": {
|
||||
"inbox": "Boîte de réception",
|
||||
"sent": "Envoyés",
|
||||
@@ -216,7 +219,11 @@
|
||||
"language": {
|
||||
"title": "Langue",
|
||||
"english": "English",
|
||||
"french": "Français"
|
||||
"french": "Français",
|
||||
"select_language": "Sélectionner la langue",
|
||||
"switch_to_english": "Passer à l'anglais",
|
||||
"switch_to_french": "Passer au français",
|
||||
"switching": "Changement de langue..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
@@ -244,6 +251,10 @@
|
||||
"dark": "Sombre",
|
||||
"system": "Système"
|
||||
},
|
||||
"language": {
|
||||
"label": "Langue",
|
||||
"description": "Choisissez votre langue préférée"
|
||||
},
|
||||
"font_size": {
|
||||
"label": "Taille de police",
|
||||
"description": "Ajustez la taille du texte pour une meilleure lisibilité",
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import { locales, defaultLocale } from './i18n/request';
|
||||
import createIntlMiddleware from 'next-intl/middleware';
|
||||
import { routing } from './i18n/routing';
|
||||
|
||||
export default createMiddleware({
|
||||
locales,
|
||||
defaultLocale,
|
||||
localePrefix: 'always', // Always show locale in URL for consistency
|
||||
localeDetection: true // Enable browser language detection
|
||||
});
|
||||
export default createIntlMiddleware(routing);
|
||||
|
||||
export const config = {
|
||||
// Skip all paths that should not be internationalized
|
||||
|
||||
Reference in New Issue
Block a user