feat(i18n): Complete internationalization with timezone fixes and language switching

Completes full internationalization coverage for the webmail application with enhanced language support:

- Replace all remaining hardcoded English strings with translation keys
- Add timezone auto-detection to prevent hydration mismatches
- Enable instant client-side language switching without page reload
- Add 60+ new translation keys in English and French locales
- Improve language switcher component with proper state management
- Add health check endpoint for container orchestration

All user-facing text now supports English and French with no hardcoded fallbacks, providing a fully localized experience.
This commit is contained in:
Matthieu MALVACHE
2026-01-08 04:29:19 +01:00
committed by Matthieu MALVACHE
parent b2b479359f
commit 0d68851b63
20 changed files with 638 additions and 192 deletions
+2 -3
View File
@@ -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>
+4 -4
View File
@@ -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>
);
+4 -5
View File
@@ -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('/');
}
};
+65 -32
View File
@@ -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,8 +30,8 @@ import { DragDropProvider } from "@/contexts/drag-drop-context";
export default function Home() {
const router = useRouter();
const params = useParams();
const t = useTranslations();
const tCommon = useTranslations('common');
const [showComposer, setShowComposer] = useState(false);
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [initialCheckDone, setInitialCheckDone] = useState(false);
@@ -43,9 +43,9 @@ export default function Home() {
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading } = useAuthStore();
// Mobile responsive hooks
const { isMobile } = useDeviceDetection();
const { activeView, sidebarOpen, setSidebarOpen, setActiveView } = useUIStore();
// Mobile/tablet responsive hooks
const { isMobile, isTablet } = useDeviceDetection();
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible } = useUIStore();
const {
emails,
mailboxes,
@@ -125,6 +125,9 @@ export default function Home() {
if (isMobile) {
setActiveView("list");
}
if (isTablet) {
setTabletListVisible(true);
}
},
onReply: () => {
if (selectedEmail) handleReply();
@@ -180,7 +183,7 @@ export default function Home() {
clearSelection();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [emails, selectedEmail, client, selectedMailbox, isMobile]);
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet]);
// Initialize keyboard shortcuts
useKeyboardShortcuts({
@@ -192,7 +195,7 @@ export default function Home() {
// Update page title based on context
useEffect(() => {
let title = "Webmail";
let title = tCommon('app_title');
if (showComposer) {
// Composing email
@@ -202,11 +205,11 @@ export default function Home() {
replyAll: t('email_composer.reply_all'),
forward: t('email_composer.forward'),
}[composerMode] || t('email_composer.new_message');
title = `${modeText} - Webmail`;
title = `${modeText} - ${tCommon('app_title')}`;
} else if (selectedEmail) {
// Reading email
const subject = selectedEmail.subject || t('email_viewer.no_subject');
title = `${subject} - Webmail`;
title = `${subject} - ${tCommon('app_title')}`;
} else if (selectedMailbox && mailboxes.length > 0) {
// Mailbox view
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
@@ -214,13 +217,13 @@ export default function Home() {
const mailboxName = mailbox.name;
const unreadCount = mailbox.unreadEmails || 0;
title = unreadCount > 0
? `${mailboxName} (${unreadCount}) - Webmail`
: `${mailboxName} - Webmail`;
? `${mailboxName} (${unreadCount}) - ${tCommon('app_title')}`
: `${mailboxName} - ${tCommon('app_title')}`;
}
}
document.title = title;
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t]);
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, tCommon]);
// Check auth on mount
useEffect(() => {
@@ -232,9 +235,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(() => {
@@ -346,6 +349,18 @@ export default function Home() {
}
}, [newEmailNotification, clearNewEmailNotification]);
// Lock body scroll when sidebar is open on mobile/tablet
useEffect(() => {
if ((isMobile || isTablet) && sidebarOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isMobile, isTablet, sidebarOpen]);
const handleEmailSend = async (data: {
to: string[];
cc: string[];
@@ -472,6 +487,11 @@ export default function Home() {
setActiveView("list");
}
// On tablet, show the list again
if (isTablet) {
setTabletListVisible(true);
}
if (client) {
// If there's an active search, re-run it in the new mailbox
if (searchQuery) {
@@ -484,7 +504,7 @@ export default function Home() {
const handleLogout = () => {
logout();
router.push(`/${params.locale}/login`);
router.push('/login');
};
const handleSearch = async (query: string) => {
@@ -556,6 +576,11 @@ export default function Home() {
setActiveView("viewer");
}
// On tablet, hide the list to maximize viewer space
if (isTablet) {
setTabletListVisible(false);
}
// Fetch the full content
try {
// Find selected mailbox to determine accountId (for shared folders)
@@ -629,24 +654,24 @@ export default function Home() {
return (
<DragDropProvider>
<div className="flex h-screen bg-background overflow-hidden">
{/* Mobile Sidebar Overlay Backdrop */}
{isMobile && sidebarOpen && (
{/* Mobile/Tablet Sidebar Overlay Backdrop */}
{(isMobile || isTablet) && sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar - overlay on mobile, fixed on desktop */}
{/* Sidebar - overlay on mobile/tablet, fixed on desktop */}
<div
className={cn(
"flex-shrink-0 h-full z-50",
// Mobile: fixed overlay
"max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:w-72",
"max-md:transform max-md:transition-transform max-md:duration-300 max-md:ease-in-out",
isMobile && !sidebarOpen && "max-md:-translate-x-full",
// Mobile/Tablet: fixed overlay
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72",
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
!sidebarOpen && "max-lg:-translate-x-full",
// Desktop: normal flow
"md:relative md:translate-x-0"
"lg:relative lg:translate-x-0"
)}
>
<ErrorBoundary fallback={SidebarErrorFallback}>
@@ -660,6 +685,7 @@ export default function Home() {
if (isMobile) setSidebarOpen(false);
}}
onLogout={handleLogout}
onSidebarClose={() => setSidebarOpen(false)}
onSearch={handleSearch}
onClearSearch={handleClearSearch}
activeSearchQuery={searchQuery}
@@ -671,15 +697,18 @@ export default function Home() {
{/* Main Content Area */}
<div className="flex flex-1 min-w-0 h-full">
{/* Email List - full width on mobile, fixed width on desktop */}
{/* Email List - full width on mobile, fixed width on tablet/desktop */}
<div
className={cn(
"flex flex-col h-full bg-background border-r border-border",
// Mobile: full width, hidden when viewing email
"max-md:flex-1 max-md:border-r-0",
isMobile && activeView !== "list" && "max-md:hidden",
// Desktop: fixed width
"md:w-80 lg:w-96 md:flex-shrink-0 md:shadow-sm"
// Tablet/Desktop: fixed width with collapse animation
"md:w-80 lg:w-96 md:flex-shrink-0 md:shadow-sm",
"transition-all duration-200 ease-out",
// Tablet: collapse when email selected
isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
)}
>
{/* Mobile Header for List View */}
@@ -742,14 +771,14 @@ export default function Home() {
</ErrorBoundary>
</div>
{/* Email Viewer - full screen on mobile, flex on desktop */}
{/* Email Viewer - full screen on mobile, flex on tablet/desktop */}
<div
className={cn(
"flex flex-col h-full bg-background",
// Mobile: full screen overlay when active
"max-md:fixed max-md:inset-0 max-md:z-30",
isMobile && activeView !== "viewer" && "max-md:hidden",
// Desktop: flex grow
// Tablet/Desktop: flex grow
"md:flex-1 md:relative"
)}
>
@@ -798,6 +827,10 @@ export default function Home() {
}}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
onBack={() => {
setTabletListVisible(true);
selectEmail(null);
}}
currentUserEmail={client?.["username"]}
currentUserName={client?.["username"]?.split("@")[0]}
className={isMobile ? "flex-1" : undefined}
@@ -810,10 +843,10 @@ export default function Home() {
{/* Email Composer Modal */}
{showComposer && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 md:p-0">
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 lg:p-0">
<div className={cn(
"w-full h-full md:h-[600px] md:max-w-3xl",
"max-md:flex max-md:flex-col"
"w-full h-full lg:h-[600px] lg:max-w-3xl",
"max-lg:flex max-lg:flex-col"
)}>
<ErrorBoundary
fallback={ComposerErrorFallback}
+2 -3
View File
@@ -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" />
+126
View File
@@ -0,0 +1,126 @@
import { NextResponse } from 'next/server';
import { NextRequest } from 'next/server';
// Health check thresholds
const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage
const MEMORY_CRITICAL_THRESHOLD = 0.95; // 95% heap usage
interface HealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy';
timestamp: string;
uptime?: number;
version?: string;
memory?: {
heapUsed: number;
heapTotal: number;
rss: number;
external: number;
heapUsagePercent: number;
};
environment?: string;
nodeVersion?: string;
warnings?: string[];
reason?: string;
}
/**
* Health check endpoint for container orchestration
*
* GET /api/health - Basic health check (returns 200 OK or 503 Service Unavailable)
* GET /api/health?detailed=true - Detailed diagnostics with memory stats
* HEAD /api/health - Lightweight health check (status code only)
*
* Health status based on Node.js heap usage:
* - Healthy (200): < 85% heap usage
* - Degraded (200): 85-95% heap usage (warnings in detailed mode)
* - Unhealthy (503): > 95% heap usage
*/
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const detailed = searchParams.get('detailed') === 'true';
try {
const timestamp = new Date().toISOString();
const memUsage = process.memoryUsage();
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
// Determine health status based on memory usage
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
const warnings: string[] = [];
let httpStatus = 200;
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
status = 'unhealthy';
httpStatus = 503;
} else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) {
status = 'degraded';
warnings.push(`Memory usage high: ${heapUsagePercent.toFixed(1)}%`);
}
// Build response
const response: HealthStatus = {
status,
timestamp,
};
if (status === 'unhealthy') {
response.reason = `Memory usage critical: ${heapUsagePercent.toFixed(1)}%`;
}
// Add detailed information if requested
if (detailed) {
response.uptime = process.uptime();
response.version = process.env.npm_package_version || '0.1.0';
response.memory = {
heapUsed: memUsage.heapUsed,
heapTotal: memUsage.heapTotal,
rss: memUsage.rss,
external: memUsage.external,
heapUsagePercent: Number(heapUsagePercent.toFixed(2)),
};
response.environment = process.env.NODE_ENV || 'development';
response.nodeVersion = process.version;
if (warnings.length > 0) {
response.warnings = warnings;
}
}
return NextResponse.json(response, {
status: httpStatus,
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
});
} catch (error) {
return NextResponse.json(
{
status: 'unhealthy',
timestamp: new Date().toISOString(),
reason: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 503 }
);
}
}
/**
* HEAD method for ultra-lightweight health checks
*/
export async function HEAD() {
try {
const memUsage = process.memoryUsage();
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
return new Response(null, { status: 503 });
}
return new Response(null, { status: 200 });
} catch {
return new Response(null, { status: 503 });
}
}