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:
@@ -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('/');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+65
-32
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user