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 { useTranslations } from "next-intl";
|
||||||
import { AlertCircle, RefreshCw, Home } from "lucide-react";
|
import { AlertCircle, RefreshCw, Home } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route-level error boundary for locale pages.
|
* Route-level error boundary for locale pages.
|
||||||
@@ -18,7 +18,6 @@ export default function LocaleError({
|
|||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("errors");
|
const t = useTranslations("errors");
|
||||||
const params = useParams();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,7 +37,7 @@ export default function LocaleError({
|
|||||||
{t("page_error_description")}
|
{t("page_error_description")}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3 justify-center">
|
<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" />
|
<Home className="w-4 h-4 mr-2" />
|
||||||
{t("go_home")}
|
{t("go_home")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { notFound } from "next/navigation";
|
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 { ThemeProvider } from "@/components/providers/theme-provider";
|
||||||
import { locales } from "@/i18n/request";
|
import { locales } from "@/i18n/routing";
|
||||||
import "../globals.css";
|
import "../globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -66,11 +66,11 @@ export default async function LocaleLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
<IntlProvider locale={locale} messages={messages}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
{children}
|
{children}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</NextIntlClientProvider>
|
</IntlProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useRouter, useParams } from "next/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -11,7 +11,6 @@ import { Mail, AlertCircle, Loader2, X } from "lucide-react";
|
|||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
|
||||||
const t = useTranslations("login");
|
const t = useTranslations("login");
|
||||||
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||||
const { appName, jmapServerUrl: serverUrl, isLoading: configLoading, error: configError } = useConfig();
|
const { appName, jmapServerUrl: serverUrl, isLoading: configLoading, error: configError } = useConfig();
|
||||||
@@ -53,9 +52,9 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
router.push(`/${params.locale}`);
|
router.push('/');
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, router, params.locale]);
|
}, [isAuthenticated, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearError();
|
clearError();
|
||||||
@@ -229,7 +228,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
saveUsername(formData.username);
|
saveUsername(formData.username);
|
||||||
router.push(`/${params.locale}`);
|
router.push('/');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+65
-32
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useRef, useMemo } from "react";
|
import { useEffect, useState, useRef, useMemo } from "react";
|
||||||
import { useRouter, useParams } from "next/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Sidebar } from "@/components/layout/sidebar";
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
import { EmailList } from "@/components/email/email-list";
|
import { EmailList } from "@/components/email/email-list";
|
||||||
@@ -30,8 +30,8 @@ import { DragDropProvider } from "@/contexts/drag-drop-context";
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
const [showComposer, setShowComposer] = useState(false);
|
const [showComposer, setShowComposer] = useState(false);
|
||||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(false);
|
const [initialCheckDone, setInitialCheckDone] = useState(false);
|
||||||
@@ -43,9 +43,9 @@ export default function Home() {
|
|||||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||||
|
|
||||||
// Mobile responsive hooks
|
// Mobile/tablet responsive hooks
|
||||||
const { isMobile } = useDeviceDetection();
|
const { isMobile, isTablet } = useDeviceDetection();
|
||||||
const { activeView, sidebarOpen, setSidebarOpen, setActiveView } = useUIStore();
|
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible } = useUIStore();
|
||||||
const {
|
const {
|
||||||
emails,
|
emails,
|
||||||
mailboxes,
|
mailboxes,
|
||||||
@@ -125,6 +125,9 @@ export default function Home() {
|
|||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setActiveView("list");
|
setActiveView("list");
|
||||||
}
|
}
|
||||||
|
if (isTablet) {
|
||||||
|
setTabletListVisible(true);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onReply: () => {
|
onReply: () => {
|
||||||
if (selectedEmail) handleReply();
|
if (selectedEmail) handleReply();
|
||||||
@@ -180,7 +183,7 @@ export default function Home() {
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}), [emails, selectedEmail, client, selectedMailbox, isMobile]);
|
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet]);
|
||||||
|
|
||||||
// Initialize keyboard shortcuts
|
// Initialize keyboard shortcuts
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
@@ -192,7 +195,7 @@ export default function Home() {
|
|||||||
|
|
||||||
// Update page title based on context
|
// Update page title based on context
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let title = "Webmail";
|
let title = tCommon('app_title');
|
||||||
|
|
||||||
if (showComposer) {
|
if (showComposer) {
|
||||||
// Composing email
|
// Composing email
|
||||||
@@ -202,11 +205,11 @@ export default function Home() {
|
|||||||
replyAll: t('email_composer.reply_all'),
|
replyAll: t('email_composer.reply_all'),
|
||||||
forward: t('email_composer.forward'),
|
forward: t('email_composer.forward'),
|
||||||
}[composerMode] || t('email_composer.new_message');
|
}[composerMode] || t('email_composer.new_message');
|
||||||
title = `${modeText} - Webmail`;
|
title = `${modeText} - ${tCommon('app_title')}`;
|
||||||
} else if (selectedEmail) {
|
} else if (selectedEmail) {
|
||||||
// Reading email
|
// Reading email
|
||||||
const subject = selectedEmail.subject || t('email_viewer.no_subject');
|
const subject = selectedEmail.subject || t('email_viewer.no_subject');
|
||||||
title = `${subject} - Webmail`;
|
title = `${subject} - ${tCommon('app_title')}`;
|
||||||
} else if (selectedMailbox && mailboxes.length > 0) {
|
} else if (selectedMailbox && mailboxes.length > 0) {
|
||||||
// Mailbox view
|
// Mailbox view
|
||||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||||
@@ -214,13 +217,13 @@ export default function Home() {
|
|||||||
const mailboxName = mailbox.name;
|
const mailboxName = mailbox.name;
|
||||||
const unreadCount = mailbox.unreadEmails || 0;
|
const unreadCount = mailbox.unreadEmails || 0;
|
||||||
title = unreadCount > 0
|
title = unreadCount > 0
|
||||||
? `${mailboxName} (${unreadCount}) - Webmail`
|
? `${mailboxName} (${unreadCount}) - ${tCommon('app_title')}`
|
||||||
: `${mailboxName} - Webmail`;
|
: `${mailboxName} - ${tCommon('app_title')}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.title = title;
|
document.title = title;
|
||||||
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t]);
|
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, tCommon]);
|
||||||
|
|
||||||
// Check auth on mount
|
// Check auth on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -232,9 +235,9 @@ export default function Home() {
|
|||||||
// Redirect to login if not authenticated
|
// Redirect to login if not authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
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)
|
// Load mailboxes and emails when authenticated (only if not already loaded)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -346,6 +349,18 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, [newEmailNotification, clearNewEmailNotification]);
|
}, [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: {
|
const handleEmailSend = async (data: {
|
||||||
to: string[];
|
to: string[];
|
||||||
cc: string[];
|
cc: string[];
|
||||||
@@ -472,6 +487,11 @@ export default function Home() {
|
|||||||
setActiveView("list");
|
setActiveView("list");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On tablet, show the list again
|
||||||
|
if (isTablet) {
|
||||||
|
setTabletListVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
if (client) {
|
if (client) {
|
||||||
// If there's an active search, re-run it in the new mailbox
|
// If there's an active search, re-run it in the new mailbox
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
@@ -484,7 +504,7 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
router.push(`/${params.locale}/login`);
|
router.push('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = async (query: string) => {
|
const handleSearch = async (query: string) => {
|
||||||
@@ -556,6 +576,11 @@ export default function Home() {
|
|||||||
setActiveView("viewer");
|
setActiveView("viewer");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On tablet, hide the list to maximize viewer space
|
||||||
|
if (isTablet) {
|
||||||
|
setTabletListVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch the full content
|
// Fetch the full content
|
||||||
try {
|
try {
|
||||||
// Find selected mailbox to determine accountId (for shared folders)
|
// Find selected mailbox to determine accountId (for shared folders)
|
||||||
@@ -629,24 +654,24 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<DragDropProvider>
|
<DragDropProvider>
|
||||||
<div className="flex h-screen bg-background overflow-hidden">
|
<div className="flex h-screen bg-background overflow-hidden">
|
||||||
{/* Mobile Sidebar Overlay Backdrop */}
|
{/* Mobile/Tablet Sidebar Overlay Backdrop */}
|
||||||
{isMobile && sidebarOpen && (
|
{(isMobile || isTablet) && sidebarOpen && (
|
||||||
<div
|
<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)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sidebar - overlay on mobile, fixed on desktop */}
|
{/* Sidebar - overlay on mobile/tablet, fixed on desktop */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 h-full z-50",
|
"flex-shrink-0 h-full z-50",
|
||||||
// Mobile: fixed overlay
|
// Mobile/Tablet: fixed overlay
|
||||||
"max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:w-72",
|
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72",
|
||||||
"max-md:transform max-md:transition-transform max-md:duration-300 max-md:ease-in-out",
|
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
|
||||||
isMobile && !sidebarOpen && "max-md:-translate-x-full",
|
!sidebarOpen && "max-lg:-translate-x-full",
|
||||||
// Desktop: normal flow
|
// Desktop: normal flow
|
||||||
"md:relative md:translate-x-0"
|
"lg:relative lg:translate-x-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ErrorBoundary fallback={SidebarErrorFallback}>
|
<ErrorBoundary fallback={SidebarErrorFallback}>
|
||||||
@@ -660,6 +685,7 @@ export default function Home() {
|
|||||||
if (isMobile) setSidebarOpen(false);
|
if (isMobile) setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
onSidebarClose={() => setSidebarOpen(false)}
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
onClearSearch={handleClearSearch}
|
onClearSearch={handleClearSearch}
|
||||||
activeSearchQuery={searchQuery}
|
activeSearchQuery={searchQuery}
|
||||||
@@ -671,15 +697,18 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex flex-1 min-w-0 h-full">
|
<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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col h-full bg-background border-r border-border",
|
"flex flex-col h-full bg-background border-r border-border",
|
||||||
// Mobile: full width, hidden when viewing email
|
// Mobile: full width, hidden when viewing email
|
||||||
"max-md:flex-1 max-md:border-r-0",
|
"max-md:flex-1 max-md:border-r-0",
|
||||||
isMobile && activeView !== "list" && "max-md:hidden",
|
isMobile && activeView !== "list" && "max-md:hidden",
|
||||||
// Desktop: fixed width
|
// Tablet/Desktop: fixed width with collapse animation
|
||||||
"md:w-80 lg:w-96 md:flex-shrink-0 md:shadow-sm"
|
"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 */}
|
{/* Mobile Header for List View */}
|
||||||
@@ -742,14 +771,14 @@ export default function Home() {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email Viewer - full screen on mobile, flex on desktop */}
|
{/* Email Viewer - full screen on mobile, flex on tablet/desktop */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col h-full bg-background",
|
"flex flex-col h-full bg-background",
|
||||||
// Mobile: full screen overlay when active
|
// Mobile: full screen overlay when active
|
||||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||||
// Desktop: flex grow
|
// Tablet/Desktop: flex grow
|
||||||
"md:flex-1 md:relative"
|
"md:flex-1 md:relative"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -798,6 +827,10 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
onDownloadAttachment={handleDownloadAttachment}
|
onDownloadAttachment={handleDownloadAttachment}
|
||||||
onQuickReply={handleQuickReply}
|
onQuickReply={handleQuickReply}
|
||||||
|
onBack={() => {
|
||||||
|
setTabletListVisible(true);
|
||||||
|
selectEmail(null);
|
||||||
|
}}
|
||||||
currentUserEmail={client?.["username"]}
|
currentUserEmail={client?.["username"]}
|
||||||
currentUserName={client?.["username"]?.split("@")[0]}
|
currentUserName={client?.["username"]?.split("@")[0]}
|
||||||
className={isMobile ? "flex-1" : undefined}
|
className={isMobile ? "flex-1" : undefined}
|
||||||
@@ -810,10 +843,10 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Email Composer Modal */}
|
{/* Email Composer Modal */}
|
||||||
{showComposer && (
|
{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(
|
<div className={cn(
|
||||||
"w-full h-full md:h-[600px] md:max-w-3xl",
|
"w-full h-full lg:h-[600px] lg:max-w-3xl",
|
||||||
"max-md:flex max-md:flex-col"
|
"max-lg:flex max-lg:flex-col"
|
||||||
)}>
|
)}>
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={ComposerErrorFallback}
|
fallback={ComposerErrorFallback}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { ArrowLeft, Settings as SettingsIcon } from 'lucide-react';
|
import { ArrowLeft, Settings as SettingsIcon } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -15,7 +15,6 @@ type Tab = 'appearance' | 'email' | 'account' | 'advanced';
|
|||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
|
||||||
const t = useTranslations('settings');
|
const t = useTranslations('settings');
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('appearance');
|
const [activeTab, setActiveTab] = useState<Tab>('appearance');
|
||||||
|
|
||||||
@@ -35,7 +34,7 @@ export default function SettingsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => router.push(`/${params.locale}`)}
|
onClick={() => router.push('/')}
|
||||||
className="w-full justify-start"
|
className="w-full justify-start"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -315,19 +315,19 @@ export function EmailComposer({
|
|||||||
{saveStatus === 'saving' && (
|
{saveStatus === 'saving' && (
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<Save className="w-3 h-3 animate-pulse" />
|
<Save className="w-3 h-3 animate-pulse" />
|
||||||
<span>Saving...</span>
|
<span>{t('saving')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{saveStatus === 'saved' && (
|
{saveStatus === 'saved' && (
|
||||||
<div className="flex items-center gap-1 text-xs text-green-600">
|
<div className="flex items-center gap-1 text-xs text-green-600">
|
||||||
<Check className="w-3 h-3" />
|
<Check className="w-3 h-3" />
|
||||||
<span>Draft saved</span>
|
<span>{t('draft_saved')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{saveStatus === 'error' && (
|
{saveStatus === 'error' && (
|
||||||
<div className="flex items-center gap-1 text-xs text-red-600">
|
<div className="flex items-center gap-1 text-xs text-red-600">
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
<span>Failed to save</span>
|
<span>{t('save_failed')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -366,7 +366,7 @@ export function EmailComposer({
|
|||||||
<span className="text-sm text-muted-foreground w-16">{t('to')}:</span>
|
<span className="text-sm text-muted-foreground w-16">{t('to')}:</span>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Recipient email addresses (comma separated)"
|
placeholder={t('to_placeholder')}
|
||||||
value={to}
|
value={to}
|
||||||
onChange={(e) => setTo(e.target.value)}
|
onChange={(e) => setTo(e.target.value)}
|
||||||
className="flex-1 border-0 focus-visible:ring-0"
|
className="flex-1 border-0 focus-visible:ring-0"
|
||||||
@@ -393,10 +393,10 @@ export function EmailComposer({
|
|||||||
|
|
||||||
{showCc && (
|
{showCc && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-muted-foreground w-16">Cc:</span>
|
<span className="text-sm text-muted-foreground w-16">{t('cc_label')}</span>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Cc recipients (comma separated)"
|
placeholder={t('cc_placeholder')}
|
||||||
value={cc}
|
value={cc}
|
||||||
onChange={(e) => setCc(e.target.value)}
|
onChange={(e) => setCc(e.target.value)}
|
||||||
className="flex-1 border-0 focus-visible:ring-0"
|
className="flex-1 border-0 focus-visible:ring-0"
|
||||||
@@ -406,10 +406,10 @@ export function EmailComposer({
|
|||||||
|
|
||||||
{showBcc && (
|
{showBcc && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-muted-foreground w-16">Bcc:</span>
|
<span className="text-sm text-muted-foreground w-16">{t('bcc_label')}</span>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Bcc recipients (comma separated)"
|
placeholder={t('bcc_placeholder')}
|
||||||
value={bcc}
|
value={bcc}
|
||||||
onChange={(e) => setBcc(e.target.value)}
|
onChange={(e) => setBcc(e.target.value)}
|
||||||
className="flex-1 border-0 focus-visible:ring-0"
|
className="flex-1 border-0 focus-visible:ring-0"
|
||||||
@@ -418,10 +418,10 @@ export function EmailComposer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-muted-foreground w-16">Subject:</span>
|
<span className="text-sm text-muted-foreground w-16">{t('subject_label')}</span>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Subject"
|
placeholder={t('subject_placeholder')}
|
||||||
value={subject}
|
value={subject}
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
className="flex-1 border-0 focus-visible:ring-0"
|
className="flex-1 border-0 focus-visible:ring-0"
|
||||||
@@ -432,7 +432,7 @@ export function EmailComposer({
|
|||||||
<div className="flex-1 px-4 py-3 min-h-0">
|
<div className="flex-1 px-4 py-3 min-h-0">
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full h-full resize-none outline-none text-sm bg-transparent text-foreground placeholder:text-muted-foreground"
|
className="w-full h-full resize-none outline-none text-sm bg-transparent text-foreground placeholder:text-muted-foreground"
|
||||||
placeholder="Compose email..."
|
placeholder={t('body_placeholder')}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={(e) => setBody(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -459,7 +459,7 @@ export function EmailComposer({
|
|||||||
)}
|
)}
|
||||||
<span className="max-w-[200px] truncate">{att.file.name}</span>
|
<span className="max-w-[200px] truncate">{att.file.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
({(att.file.size / 1024).toFixed(1)} KB)
|
({(att.file.size / 1024).toFixed(1)} {t('file_size_kb')})
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeAttachment(index)}
|
onClick={() => removeAttachment(index)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
MoreVertical,
|
MoreVertical,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
ChevronLeft,
|
||||||
Download,
|
Download,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -48,6 +49,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
|
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||||
|
|
||||||
interface EmailViewerProps {
|
interface EmailViewerProps {
|
||||||
email: Email | null;
|
email: Email | null;
|
||||||
@@ -62,6 +65,7 @@ interface EmailViewerProps {
|
|||||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||||
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
|
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
|
||||||
onQuickReply?: (body: string) => Promise<void>;
|
onQuickReply?: (body: string) => Promise<void>;
|
||||||
|
onBack?: () => void;
|
||||||
currentUserEmail?: string;
|
currentUserEmail?: string;
|
||||||
currentUserName?: string;
|
currentUserName?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -127,15 +131,21 @@ export function EmailViewer({
|
|||||||
onSetColorTag,
|
onSetColorTag,
|
||||||
onDownloadAttachment,
|
onDownloadAttachment,
|
||||||
onQuickReply,
|
onQuickReply,
|
||||||
|
onBack,
|
||||||
currentUserEmail,
|
currentUserEmail,
|
||||||
currentUserName,
|
currentUserName,
|
||||||
className,
|
className,
|
||||||
}: EmailViewerProps) {
|
}: EmailViewerProps) {
|
||||||
const t = useTranslations('email_viewer');
|
const t = useTranslations('email_viewer');
|
||||||
const tNotifications = useTranslations('notifications');
|
const tNotifications = useTranslations('notifications');
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||||
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
||||||
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
||||||
|
|
||||||
|
// Tablet list visibility
|
||||||
|
const { isTablet } = useDeviceDetection();
|
||||||
|
const { tabletListVisible } = useUIStore();
|
||||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||||
const [allowExternalContent, setAllowExternalContent] = useState(false);
|
const [allowExternalContent, setAllowExternalContent] = useState(false);
|
||||||
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
||||||
@@ -467,26 +477,26 @@ export function EmailViewer({
|
|||||||
<div className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden animate-in fade-in duration-200", className)}>
|
<div className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden animate-in fade-in duration-200", className)}>
|
||||||
{/* Loading Header Skeleton - gentler animation */}
|
{/* Loading Header Skeleton - gentler animation */}
|
||||||
<div className="bg-background border-b border-border">
|
<div className="bg-background border-b border-border">
|
||||||
<div className="px-4 md:px-6 py-3 md:py-4">
|
<div className="px-4 lg:px-6 py-3 lg:py-4">
|
||||||
<div className="flex items-start justify-between gap-2 md:gap-4">
|
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||||
<div className="flex-1 min-w-0 space-y-2 md:space-y-3">
|
<div className="flex-1 min-w-0 space-y-2 lg:space-y-3">
|
||||||
<div className="h-6 md:h-8 bg-muted/60 rounded-md w-3/4"></div>
|
<div className="h-6 lg:h-8 bg-muted/60 rounded-md w-3/4"></div>
|
||||||
<div className="flex items-center gap-2 md:gap-3">
|
<div className="flex items-center gap-2 lg:gap-3">
|
||||||
<div className="h-3 md:h-4 bg-muted/60 rounded w-24 md:w-32"></div>
|
<div className="h-3 lg:h-4 bg-muted/60 rounded w-24 lg:w-32"></div>
|
||||||
<div className="h-3 md:h-4 bg-muted/60 rounded w-16 md:w-24"></div>
|
<div className="h-3 lg:h-4 bg-muted/60 rounded w-16 lg:w-24"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 md:gap-2">
|
<div className="flex items-center gap-1 lg:gap-2">
|
||||||
<div className="h-8 w-8 md:w-20 bg-muted/60 rounded"></div>
|
<div className="h-8 w-8 lg:w-20 bg-muted/60 rounded"></div>
|
||||||
<div className="h-8 w-8 bg-muted/60 rounded hidden md:block"></div>
|
<div className="h-8 w-8 bg-muted/60 rounded hidden lg:block"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading Sender Info Skeleton */}
|
{/* Loading Sender Info Skeleton */}
|
||||||
<div className="px-4 md:px-6 pb-3 md:pb-4">
|
<div className="px-4 lg:px-6 pb-3 lg:pb-4">
|
||||||
<div className="flex items-start gap-3 md:gap-4">
|
<div className="flex items-start gap-3 lg:gap-4">
|
||||||
<div className="w-10 h-10 md:w-12 md:h-12 bg-muted/60 rounded-full"></div>
|
<div className="w-10 h-10 lg:w-12 lg:h-12 bg-muted/60 rounded-full"></div>
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="h-4 bg-muted/60 rounded w-48"></div>
|
<div className="h-4 bg-muted/60 rounded w-48"></div>
|
||||||
<div className="h-3 bg-muted/60 rounded w-64"></div>
|
<div className="h-3 bg-muted/60 rounded w-64"></div>
|
||||||
@@ -518,8 +528,8 @@ export function EmailViewer({
|
|||||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
|
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
|
||||||
<Mail className="w-10 h-10 text-muted-foreground" />
|
<Mail className="w-10 h-10 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-foreground mb-2">No conversation selected</h3>
|
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
||||||
<p className="text-muted-foreground">Choose a conversation from the list to read it here</p>
|
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -539,22 +549,36 @@ export function EmailViewer({
|
|||||||
<div className="absolute inset-0 bg-background/60 backdrop-blur-[2px] z-50 flex items-center justify-center animate-in fade-in duration-200">
|
<div className="absolute inset-0 bg-background/60 backdrop-blur-[2px] z-50 flex items-center justify-center animate-in fade-in duration-200">
|
||||||
<div className="bg-background rounded-lg shadow-lg border border-border p-4 flex items-center gap-3">
|
<div className="bg-background rounded-lg shadow-lg border border-border p-4 flex items-center gap-3">
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||||
<span className="text-sm font-medium text-foreground">Loading email...</span>
|
<span className="text-sm font-medium text-foreground">{t('loading_email')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Modern Header Section */}
|
{/* Subject Bar - sticky on mobile/tablet for quick actions */}
|
||||||
<div className="bg-background border-b border-border">
|
<div className={cn(
|
||||||
{/* Subject Bar */}
|
"bg-background border-b border-border",
|
||||||
<div className="px-4 md:px-6 py-3 md:py-4">
|
"max-lg:sticky max-lg:top-0 max-lg:z-10"
|
||||||
<div className="flex items-start justify-between gap-2 md:gap-4">
|
)}>
|
||||||
|
<div className="px-4 lg:px-6 py-3 lg:py-4">
|
||||||
|
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||||
|
{/* Tablet Back Button - show when list is hidden */}
|
||||||
|
{isTablet && !tabletListVisible && onBack && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onBack}
|
||||||
|
className="h-10 w-10 flex-shrink-0 -ml-2"
|
||||||
|
aria-label={t('back_to_list')}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h1 className="text-lg md:text-2xl font-bold text-foreground tracking-tight truncate pr-2">
|
<h1 className="text-lg lg:text-2xl font-bold text-foreground tracking-tight truncate pr-2">
|
||||||
{email.subject || "(no subject)"}
|
{email.subject || "(no subject)"}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2 md:gap-3 mt-1.5 md:mt-2 text-xs md:text-sm text-muted-foreground flex-wrap md:flex-nowrap">
|
<div className="flex items-center gap-2 lg:gap-3 mt-1.5 lg:mt-2 text-xs lg:text-sm text-muted-foreground flex-wrap lg:flex-nowrap">
|
||||||
<span className="flex items-center gap-1 md:gap-1.5 whitespace-nowrap">
|
<span className="flex items-center gap-1 lg:gap-1.5 whitespace-nowrap">
|
||||||
<Clock className="w-3.5 h-3.5 md:w-4 md:h-4" />
|
<Clock className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
|
||||||
{new Date(email.receivedAt).toLocaleString('en-US', {
|
{new Date(email.receivedAt).toLocaleString('en-US', {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -565,14 +589,14 @@ export function EmailViewer({
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
{email.hasAttachment && (
|
{email.hasAttachment && (
|
||||||
<span className="flex items-center gap-1 md:gap-1.5 whitespace-nowrap">
|
<span className="flex items-center gap-1 lg:gap-1.5 whitespace-nowrap">
|
||||||
<Paperclip className="w-3.5 h-3.5 md:w-4 md:h-4" />
|
<Paperclip className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
|
||||||
<span className="hidden md:inline">Attachments</span>
|
<span className="hidden lg:inline">{t('attachments')}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isImportant && (
|
{isImportant && (
|
||||||
<span className="px-1.5 md:px-2 py-0.5 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full text-xs font-medium whitespace-nowrap">
|
<span className="px-1.5 lg:px-2 py-0.5 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full text-xs font-medium whitespace-nowrap">
|
||||||
Important
|
{t('important')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -582,29 +606,29 @@ export function EmailViewer({
|
|||||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||||
{/* Loading indicator */}
|
{/* Loading indicator */}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground hidden md:flex">
|
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground hidden lg:flex">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
<span className="text-xs">Loading...</span>
|
<span className="text-xs">{t('loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Primary Reply Button */}
|
{/* Primary Reply Button */}
|
||||||
<Button
|
<Button
|
||||||
onClick={onReply}
|
onClick={onReply}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="mr-1 h-8 md:h-9"
|
className="mr-1 h-8 lg:h-9"
|
||||||
title="Reply"
|
title="Reply"
|
||||||
>
|
>
|
||||||
<Reply className="w-4 h-4" />
|
<Reply className="w-4 h-4" />
|
||||||
<span className="ml-1.5 hidden md:inline">Reply</span>
|
<span className="ml-1.5 hidden lg:inline">Reply</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Reply Options Dropdown - hidden on mobile */}
|
{/* Reply Options Dropdown - hidden on mobile/tablet */}
|
||||||
<div className="relative group mr-3 hidden md:block">
|
<div className="relative group mr-3 hidden lg:block">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 hover:bg-muted"
|
className="h-8 w-8 hover:bg-muted"
|
||||||
title="More reply options"
|
title={t('more_reply_options')}
|
||||||
>
|
>
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -626,13 +650,13 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-px h-5 bg-border hidden md:block" />
|
<div className="w-px h-5 bg-border hidden lg:block" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onArchive}
|
onClick={onArchive}
|
||||||
className="h-8 w-8 hover:bg-muted hidden md:flex"
|
className="h-8 w-8 hover:bg-muted hidden lg:flex"
|
||||||
title="Archive"
|
title="Archive"
|
||||||
>
|
>
|
||||||
<Archive className="w-4 h-4 text-muted-foreground" />
|
<Archive className="w-4 h-4 text-muted-foreground" />
|
||||||
@@ -650,7 +674,7 @@ export function EmailViewer({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onToggleStar}
|
onClick={onToggleStar}
|
||||||
className="h-8 w-8 hover:bg-muted hidden md:flex"
|
className="h-8 w-8 hover:bg-muted hidden lg:flex"
|
||||||
title={isStarred ? "Unstar" : "Star"}
|
title={isStarred ? "Unstar" : "Star"}
|
||||||
>
|
>
|
||||||
<Star className={cn(
|
<Star className={cn(
|
||||||
@@ -659,13 +683,13 @@ export function EmailViewer({
|
|||||||
)} />
|
)} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="w-px h-5 bg-border mx-1 hidden md:block" />
|
<div className="w-px h-5 bg-border mx-1 hidden lg:block" />
|
||||||
|
|
||||||
{/* Compact Dynamic Color Picker - hidden on mobile */}
|
{/* Compact Dynamic Color Picker - hidden on mobile/tablet */}
|
||||||
<div className="relative group hidden md:block">
|
<div className="relative group hidden lg:block">
|
||||||
<button
|
<button
|
||||||
className="h-8 w-8 rounded hover:bg-muted flex items-center justify-center"
|
className="h-8 w-8 rounded hover:bg-muted flex items-center justify-center"
|
||||||
title="Set color"
|
title={t('set_color')}
|
||||||
>
|
>
|
||||||
<Circle className={cn(
|
<Circle className={cn(
|
||||||
"w-4 h-4",
|
"w-4 h-4",
|
||||||
@@ -710,7 +734,7 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-6 h-6 rounded-full border border-gray-300 dark:border-gray-600 hover:bg-gray-100 hover:bg-muted flex items-center justify-center"
|
className="w-6 h-6 rounded-full border border-gray-300 dark:border-gray-600 hover:bg-gray-100 hover:bg-muted flex items-center justify-center"
|
||||||
title="Remove color"
|
title={t('remove_color')}
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3 text-muted-foreground" />
|
<X className="w-3 h-3 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -725,7 +749,7 @@ export function EmailViewer({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 hover:bg-muted"
|
className="h-8 w-8 hover:bg-muted"
|
||||||
title="More actions"
|
title={t('more_actions')}
|
||||||
>
|
>
|
||||||
<MoreVertical className="w-4 h-4 text-muted-foreground" />
|
<MoreVertical className="w-4 h-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -749,21 +773,22 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sender Info */}
|
{/* Sender Info - Desktop only (hidden on mobile/tablet, they see it in scrollable content) */}
|
||||||
<div className="px-4 md:px-6 pb-3 md:pb-4">
|
<div className="hidden lg:block bg-background border-b border-border px-6 py-4">
|
||||||
<div className="flex items-start gap-3 md:gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<Avatar
|
<Avatar
|
||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
size="lg"
|
size="lg"
|
||||||
className="shadow-sm w-10 h-10 md:w-12 md:h-12"
|
className="shadow-sm w-12 h-12"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
{sender?.name || sender?.email || "Unknown"}
|
{sender?.name || sender?.email || t('unknown_sender')}
|
||||||
</span>
|
</span>
|
||||||
{sender?.email && sender?.name && (
|
{sender?.email && sender?.name && (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
@@ -783,7 +808,7 @@ export function EmailViewer({
|
|||||||
onClick={() => setShowFullHeaders(!showFullHeaders)}
|
onClick={() => setShowFullHeaders(!showFullHeaders)}
|
||||||
className="ml-1 text-blue-600 dark:text-blue-400 hover:underline"
|
className="ml-1 text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
>
|
>
|
||||||
+{email.to.length - 2} more
|
{t('more_count', { count: email.to.length - 2 })}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -809,13 +834,13 @@ export function EmailViewer({
|
|||||||
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 className="text-xs font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wider flex items-center gap-2">
|
<h3 className="text-xs font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wider flex items-center gap-2">
|
||||||
<ShieldCheck className="w-3.5 h-3.5" />
|
<ShieldCheck className="w-3.5 h-3.5" />
|
||||||
Security & Authentication
|
{t('security_authentication')}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-background p-4 space-y-3">
|
<div className="bg-background p-4 space-y-3">
|
||||||
{/* Authentication Results */}
|
{/* Authentication Results */}
|
||||||
{email.authenticationResults && (
|
{email.authenticationResults && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
{/* SPF Check */}
|
{/* SPF Check */}
|
||||||
{email.authenticationResults.spf && (
|
{email.authenticationResults.spf && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -1000,7 +1025,7 @@ export function EmailViewer({
|
|||||||
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 className="text-xs font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wider flex items-center gap-2">
|
<h3 className="text-xs font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wider flex items-center gap-2">
|
||||||
<Network className="w-3.5 h-3.5" />
|
<Network className="w-3.5 h-3.5" />
|
||||||
Technical Details
|
{t('technical_details')}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-background p-4">
|
<div className="bg-background p-4">
|
||||||
@@ -1010,7 +1035,7 @@ export function EmailViewer({
|
|||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Hash className="w-3.5 h-3.5 text-muted-foreground mt-0.5" />
|
<Hash className="w-3.5 h-3.5 text-muted-foreground mt-0.5" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="font-medium text-muted-foreground">Message-ID:</span>
|
<span className="font-medium text-muted-foreground">{t('message_id_label')}</span>
|
||||||
<div className="text-foreground break-all font-mono text-xs mt-0.5">
|
<div className="text-foreground break-all font-mono text-xs mt-0.5">
|
||||||
{email.messageId}
|
{email.messageId}
|
||||||
</div>
|
</div>
|
||||||
@@ -1024,7 +1049,7 @@ export function EmailViewer({
|
|||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Mail className="w-3.5 h-3.5 text-muted-foreground mt-0.5" />
|
<Mail className="w-3.5 h-3.5 text-muted-foreground mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="font-medium text-muted-foreground">Reply-To:</span>
|
<span className="font-medium text-muted-foreground">{t('reply_to_label')}</span>
|
||||||
<div className="flex flex-wrap gap-2 mt-1">
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
{email.replyTo.map((recipient, i) => (
|
{email.replyTo.map((recipient, i) => (
|
||||||
<span key={i} className="inline-flex items-center px-2 py-1 bg-accent/50 border border-accent rounded text-xs">
|
<span key={i} className="inline-flex items-center px-2 py-1 bg-accent/50 border border-accent rounded text-xs">
|
||||||
@@ -1043,16 +1068,20 @@ export function EmailViewer({
|
|||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Clock className="w-3.5 h-3.5 text-muted-foreground mt-0.5" />
|
<Clock className="w-3.5 h-3.5 text-muted-foreground mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="font-medium text-muted-foreground">Delivery time:</span>
|
<span className="font-medium text-muted-foreground">{t('delivery_time_label')}</span>
|
||||||
<div className="text-foreground">
|
<div className="text-foreground">
|
||||||
{(() => {
|
{(() => {
|
||||||
const diff = Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime());
|
const diff = Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime());
|
||||||
const minutes = Math.floor(diff / 60000);
|
const minutes = Math.floor(diff / 60000);
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
const days = Math.floor(hours / 24);
|
const days = Math.floor(hours / 24);
|
||||||
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ${hours % 24} hour${hours % 24 !== 1 ? 's' : ''}`;
|
const dayUnit = days > 1 ? t('time.days') : t('time.day');
|
||||||
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ${minutes % 60} minute${minutes % 60 !== 1 ? 's' : ''}`;
|
const hourUnit = (hours % 24) > 1 ? t('time.hours') : t('time.hour');
|
||||||
return `${minutes} minute${minutes > 1 ? 's' : ''}`;
|
const minuteUnit = (minutes % 60) > 1 ? t('time.minutes') : t('time.minute');
|
||||||
|
const minuteUnitSingle = minutes > 1 ? t('time.minutes') : t('time.minute');
|
||||||
|
if (days > 0) return `${days} ${dayUnit} ${hours % 24} ${hourUnit}`;
|
||||||
|
if (hours > 0) return `${hours} ${hours > 1 ? t('time.hours') : t('time.hour')} ${minutes % 60} ${minuteUnit}`;
|
||||||
|
return `${minutes} ${minuteUnitSingle}`;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1064,9 +1093,9 @@ export function EmailViewer({
|
|||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<List className="w-3.5 h-3.5 text-muted-foreground mt-0.5" />
|
<List className="w-3.5 h-3.5 text-muted-foreground mt-0.5" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="font-medium text-muted-foreground">Part of conversation:</span>
|
<span className="font-medium text-muted-foreground">{t('conversation_part_label')}</span>
|
||||||
<div className="text-foreground text-xs mt-0.5">
|
<div className="text-foreground text-xs mt-0.5">
|
||||||
{email.references.length} previous message{email.references.length > 1 ? 's' : ''} in this thread
|
{t(email.references.length === 1 ? 'previous_messages' : 'previous_messages_plural', { count: email.references.length })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1096,12 +1125,54 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email Content Area */}
|
{/* Email Content Area */}
|
||||||
<div className="flex-1 overflow-auto bg-muted/30">
|
<div className="flex-1 overflow-auto bg-muted/30">
|
||||||
|
{/* Mobile/Tablet Sender Info - scrolls with content */}
|
||||||
|
<div className="lg:hidden bg-background border-b border-border px-4 py-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Avatar
|
||||||
|
name={sender?.name}
|
||||||
|
email={sender?.email}
|
||||||
|
size="lg"
|
||||||
|
className="shadow-sm w-10 h-10"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{sender?.name || sender?.email || t('unknown_sender')}
|
||||||
|
</span>
|
||||||
|
{sender?.email && sender?.name && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
<{sender.email}>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{email.to && email.to.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1 text-sm">
|
||||||
|
<span className="text-muted-foreground">To:</span>
|
||||||
|
<span className="text-foreground truncate">
|
||||||
|
{email.to.slice(0, 2).map(r => r.name || r.email).join(", ")}
|
||||||
|
{email.to.length > 2 && ` +${email.to.length - 2}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{email.cc && email.cc.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1 text-sm">
|
||||||
|
<span className="text-muted-foreground">CC:</span>
|
||||||
|
<span className="text-foreground truncate">
|
||||||
|
{email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")}
|
||||||
|
{email.cc.length > 2 && ` +${email.cc.length - 2}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* External Content Banner - show in 'ask' or 'block' mode */}
|
{/* External Content Banner - show in 'ask' or 'block' mode */}
|
||||||
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
||||||
<div className="border-b border-border">
|
<div className="border-b border-border">
|
||||||
@@ -1268,7 +1339,7 @@ export function EmailViewer({
|
|||||||
value={quickReplyText}
|
value={quickReplyText}
|
||||||
onChange={(e) => setQuickReplyText(e.target.value)}
|
onChange={(e) => setQuickReplyText(e.target.value)}
|
||||||
onFocus={() => setIsQuickReplyFocused(true)}
|
onFocus={() => setIsQuickReplyFocused(true)}
|
||||||
placeholder="Write a quick reply..."
|
placeholder={t('quick_reply_placeholder')}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full px-3 py-2 text-sm border border-border bg-background text-foreground rounded-lg",
|
"w-full px-3 py-2 text-sm border border-border bg-background text-foreground rounded-lg",
|
||||||
"hover:border-accent focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary transition-all",
|
"hover:border-accent focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary transition-all",
|
||||||
@@ -1282,7 +1353,7 @@ export function EmailViewer({
|
|||||||
{(isQuickReplyFocused || quickReplyText) && (
|
{(isQuickReplyFocused || quickReplyText) && (
|
||||||
<div className="flex items-center justify-between gap-2 animate-in fade-in slide-in-from-top-1 duration-200">
|
<div className="flex items-center justify-between gap-2 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{quickReplyText.length > 0 && `${quickReplyText.length} characters`}
|
{quickReplyText.length > 0 && t('characters_count', { count: quickReplyText.length })}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -1294,7 +1365,7 @@ export function EmailViewer({
|
|||||||
}}
|
}}
|
||||||
disabled={isSendingQuickReply}
|
disabled={isSendingQuickReply}
|
||||||
>
|
>
|
||||||
Cancel
|
{tCommon('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1304,7 +1375,7 @@ export function EmailViewer({
|
|||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
>
|
>
|
||||||
<MoreVertical className="w-4 h-4 mr-1" />
|
<MoreVertical className="w-4 h-4 mr-1" />
|
||||||
More options
|
{t('more_options')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1327,12 +1398,12 @@ export function EmailViewer({
|
|||||||
{isSendingQuickReply ? (
|
{isSendingQuickReply ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||||
Sending...
|
{t('sending')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Reply className="w-4 h-4 mr-1" />
|
<Reply className="w-4 h-4 mr-1" />
|
||||||
Send
|
{t('send')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function MobileHeader({
|
|||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between px-4 h-14 border-b border-border bg-background shrink-0",
|
"flex items-center justify-between px-4 h-14 border-b border-border bg-background shrink-0",
|
||||||
"md:hidden", // Only visible on mobile
|
"lg:hidden", // Only visible on mobile/tablet
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -115,7 +115,7 @@ export function MobileViewerHeader({
|
|||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between px-2 h-14 border-b border-border bg-background shrink-0",
|
"flex items-center justify-between px-2 h-14 border-b border-border bg-background shrink-0",
|
||||||
"md:hidden",
|
"lg:hidden", // Only visible on mobile/tablet
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +37,7 @@ interface SidebarProps {
|
|||||||
onMailboxSelect?: (mailboxId: string) => void;
|
onMailboxSelect?: (mailboxId: string) => void;
|
||||||
onCompose?: () => void;
|
onCompose?: () => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
|
onSidebarClose?: () => void;
|
||||||
onSearch?: (query: string) => void;
|
onSearch?: (query: string) => void;
|
||||||
onClearSearch?: () => void;
|
onClearSearch?: () => void;
|
||||||
activeSearchQuery?: string;
|
activeSearchQuery?: string;
|
||||||
@@ -94,6 +95,7 @@ function MailboxTreeItem({
|
|||||||
onToggleExpand: (id: string) => void;
|
onToggleExpand: (id: string) => void;
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations('sidebar');
|
||||||
const hasChildren = node.children.length > 0;
|
const hasChildren = node.children.length > 0;
|
||||||
const isExpanded = expandedFolders.has(node.id);
|
const isExpanded = expandedFolders.has(node.id);
|
||||||
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
|
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
|
||||||
@@ -111,12 +113,11 @@ function MailboxTreeItem({
|
|||||||
<div
|
<div
|
||||||
{...(globalDragging ? dropHandlers : {})}
|
{...(globalDragging ? dropHandlers : {})}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group w-full flex items-center px-2 py-1 text-sm transition-all duration-200",
|
"group w-full flex items-center px-2 py-1 lg:py-1 max-lg:py-3 max-lg:min-h-[44px] text-sm transition-all duration-200",
|
||||||
selectedMailbox === node.id
|
selectedMailbox === node.id
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "hover:bg-muted text-foreground",
|
: "hover:bg-muted text-foreground",
|
||||||
node.depth === 0 && "font-medium", // Root folders are slightly bolder
|
node.depth === 0 && "font-medium",
|
||||||
// Drop target visual feedback
|
|
||||||
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset",
|
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset",
|
||||||
isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50"
|
isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50"
|
||||||
)}
|
)}
|
||||||
@@ -133,7 +134,7 @@ function MailboxTreeItem({
|
|||||||
"hover:bg-muted active:bg-accent"
|
"hover:bg-muted active:bg-accent"
|
||||||
)}
|
)}
|
||||||
style={{ marginLeft: indentPixels }}
|
style={{ marginLeft: indentPixels }}
|
||||||
title={isExpanded ? "Collapse" : "Expand"}
|
title={isExpanded ? t('collapse_tooltip') : t('expand_tooltip')}
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
@@ -148,7 +149,7 @@ function MailboxTreeItem({
|
|||||||
onClick={() => !isVirtualNode && onMailboxSelect?.(node.id)}
|
onClick={() => !isVirtualNode && onMailboxSelect?.(node.id)}
|
||||||
disabled={isVirtualNode}
|
disabled={isVirtualNode}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 flex items-center text-left py-1 px-1 rounded",
|
"flex-1 flex items-center text-left py-1 lg:py-1 max-lg:py-2 px-1 rounded",
|
||||||
"transition-colors duration-150",
|
"transition-colors duration-150",
|
||||||
isVirtualNode && "cursor-default"
|
isVirtualNode && "cursor-default"
|
||||||
)}
|
)}
|
||||||
@@ -208,6 +209,7 @@ export function Sidebar({
|
|||||||
onMailboxSelect,
|
onMailboxSelect,
|
||||||
onCompose,
|
onCompose,
|
||||||
onLogout,
|
onLogout,
|
||||||
|
onSidebarClose,
|
||||||
onSearch,
|
onSearch,
|
||||||
onClearSearch,
|
onClearSearch,
|
||||||
activeSearchQuery = "",
|
activeSearchQuery = "",
|
||||||
@@ -225,7 +227,6 @@ export function Sidebar({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchQuery(activeSearchQuery);
|
setSearchQuery(activeSearchQuery);
|
||||||
}, [activeSearchQuery]);
|
}, [activeSearchQuery]);
|
||||||
const params = useParams();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Load expanded folders from localStorage on mount
|
// Load expanded folders from localStorage on mount
|
||||||
@@ -313,21 +314,36 @@ export function Sidebar({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"relative flex flex-col h-full border-r transition-all duration-300 overflow-hidden",
|
"relative flex flex-col h-full border-r transition-all duration-300 overflow-hidden",
|
||||||
"bg-secondary border-border",
|
"bg-secondary border-border",
|
||||||
isCollapsed ? "w-16" : "w-64",
|
"max-lg:w-full",
|
||||||
|
isCollapsed ? "lg:w-16" : "lg:w-64",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
|
||||||
|
{/* Mobile/Tablet: Close button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onSidebarClose}
|
||||||
|
className="lg:hidden h-11 w-11 flex-shrink-0"
|
||||||
|
aria-label={t("close")}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Desktop: Collapse toggle */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
className="hidden lg:flex"
|
||||||
>
|
>
|
||||||
<Menu className="w-5 h-5" />
|
<Menu className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<Button onClick={onCompose} className="ml-2 flex-1">
|
<Button onClick={onCompose} className="flex-1">
|
||||||
<PenSquare className="w-4 h-4 mr-2" />
|
<PenSquare className="w-4 h-4 mr-2" />
|
||||||
{t("compose")}
|
{t("compose")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -421,7 +437,7 @@ export function Sidebar({
|
|||||||
<div className="border-t border-border mt-2 pt-2">
|
<div className="border-t border-border mt-2 pt-2">
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<button
|
<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"
|
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">
|
<span className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"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: Record<string, unknown>;
|
||||||
|
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);
|
||||||
|
const [timeZone, setTimeZone] = useState<string>('UTC');
|
||||||
|
|
||||||
|
// Detect user's timezone on mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const detectedTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
setTimeZone(detectedTimeZone);
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback to UTC if detection fails
|
||||||
|
console.warn('Failed to detect timezone, using UTC:', error);
|
||||||
|
setTimeZone('UTC');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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]}
|
||||||
|
timeZone={timeZone}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||||
|
|
||||||
export function AppearanceSettings() {
|
export function AppearanceSettings() {
|
||||||
@@ -25,6 +26,11 @@ export function AppearanceSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Language */}
|
||||||
|
<SettingItem label={t('language.label')} description={t('language.description')}>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{/* Font Size */}
|
{/* Font Size */}
|
||||||
<SettingItem label={t('font_size.label')} description={t('font_size.description')}>
|
<SettingItem label={t('font_size.label')} description={t('font_size.description')}>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
|
|||||||
@@ -1,46 +1,52 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { locales } from '@/i18n/request';
|
import { useLocaleStore } from '@/stores/locale-store';
|
||||||
|
|
||||||
export function LanguageSwitcher({ className }: { className?: string }) {
|
export function LanguageSwitcher({ className }: { className?: string }) {
|
||||||
const router = useRouter();
|
const currentLocale = useLocale();
|
||||||
const pathname = usePathname();
|
|
||||||
const params = useParams();
|
|
||||||
const t = useTranslations('language');
|
const t = useTranslations('language');
|
||||||
const currentLocale = params.locale as string;
|
const setLocale = useLocaleStore((state) => state.setLocale);
|
||||||
|
|
||||||
const handleLanguageChange = (newLocale: string) => {
|
const handleLanguageChange = (newLocale: string) => {
|
||||||
// Get the path without the locale prefix
|
if (newLocale === currentLocale) return;
|
||||||
const pathWithoutLocale = pathname.replace(`/${currentLocale}`, '');
|
|
||||||
|
|
||||||
// Navigate to the same page with the new locale
|
// Update locale in store (persisted to localStorage via Zustand)
|
||||||
router.push(`/${newLocale}${pathWithoutLocale}`);
|
// IntlProvider handles the translation switch
|
||||||
|
setLocale(newLocale);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ value: 'en', label: 'English' },
|
||||||
|
{ value: 'fr', label: 'Français' }
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center gap-1 p-1 bg-muted rounded-lg", className)}>
|
<div
|
||||||
{locales.map((locale) => (
|
className={cn("flex gap-2", className)}
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={t('select_language')}
|
||||||
|
>
|
||||||
|
{languages.map((lang) => (
|
||||||
<button
|
<button
|
||||||
key={locale}
|
key={lang.value}
|
||||||
onClick={() => handleLanguageChange(locale)}
|
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(
|
className={cn(
|
||||||
"flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded transition-all text-xs",
|
"px-3 py-1.5 text-xs rounded transition-colors",
|
||||||
"text-foreground",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
currentLocale === locale
|
currentLocale === lang.value
|
||||||
? "bg-background shadow-sm font-medium"
|
? "bg-primary text-primary-foreground"
|
||||||
: "hover:bg-accent/50"
|
: "bg-muted hover:bg-accent text-foreground"
|
||||||
)}
|
)}
|
||||||
title={t(locale === 'en' ? 'english' : 'french')}
|
|
||||||
>
|
>
|
||||||
{locale === 'en' ? '🇬🇧' : '🇫🇷'}
|
{lang.label}
|
||||||
<span className="hidden sm:inline">
|
|
||||||
{locale.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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';
|
import { getRequestConfig } from 'next-intl/server';
|
||||||
|
import { routing, type Locale } from './routing';
|
||||||
export const locales = ['en', 'fr'] as const;
|
|
||||||
export type Locale = (typeof locales)[number];
|
|
||||||
export const defaultLocale: Locale = 'en';
|
|
||||||
|
|
||||||
export default getRequestConfig(async ({ requestLocale }) => {
|
export default getRequestConfig(async ({ requestLocale }) => {
|
||||||
// Get the locale from the request or use default
|
let locale = await requestLocale;
|
||||||
let locale = await requestLocale || defaultLocale;
|
|
||||||
|
|
||||||
// Validate that the incoming `locale` parameter is valid
|
if (!locale || !routing.locales.includes(locale as Locale)) {
|
||||||
if (!(locales as readonly string[]).includes(locale)) {
|
locale = routing.defaultLocale;
|
||||||
locale = defaultLocale;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use static imports for better compatibility
|
// 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];
|
||||||
+61
-3
@@ -21,6 +21,7 @@
|
|||||||
"remove_from_history": "Remove from history"
|
"remove_from_history": "Remove from history"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
"close": "Close",
|
||||||
"compose": "Compose",
|
"compose": "Compose",
|
||||||
"search_placeholder": "Search mail...",
|
"search_placeholder": "Search mail...",
|
||||||
"storage": "Storage",
|
"storage": "Storage",
|
||||||
@@ -34,6 +35,9 @@
|
|||||||
"dark": "Dark mode",
|
"dark": "Dark mode",
|
||||||
"system": "System theme"
|
"system": "System theme"
|
||||||
},
|
},
|
||||||
|
"language": {
|
||||||
|
"title": "Language"
|
||||||
|
},
|
||||||
"mailboxes": {
|
"mailboxes": {
|
||||||
"inbox": "Inbox",
|
"inbox": "Inbox",
|
||||||
"sent": "Sent",
|
"sent": "Sent",
|
||||||
@@ -46,7 +50,9 @@
|
|||||||
"important": "Important"
|
"important": "Important"
|
||||||
},
|
},
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"collapse": "Collapse"
|
"collapse": "Collapse",
|
||||||
|
"expand_tooltip": "Expand",
|
||||||
|
"collapse_tooltip": "Collapse"
|
||||||
},
|
},
|
||||||
"email_list": {
|
"email_list": {
|
||||||
"no_emails": "No emails",
|
"no_emails": "No emails",
|
||||||
@@ -62,7 +68,11 @@
|
|||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "No email selected",
|
"no_email_selected": "No email selected",
|
||||||
"no_email_description": "Select an email from the list to view it here",
|
"no_email_description": "Select an email from the list to view it here",
|
||||||
|
"no_conversation_selected": "No conversation selected",
|
||||||
|
"no_conversation_description": "Choose a conversation from the list to read it here",
|
||||||
"no_subject": "(No Subject)",
|
"no_subject": "(No Subject)",
|
||||||
|
"loading_email": "Loading email...",
|
||||||
|
"loading": "Loading...",
|
||||||
"reply": "Reply",
|
"reply": "Reply",
|
||||||
"reply_all": "Reply All",
|
"reply_all": "Reply All",
|
||||||
"forward": "Forward",
|
"forward": "Forward",
|
||||||
@@ -78,6 +88,7 @@
|
|||||||
"copy_source": "Copy to clipboard",
|
"copy_source": "Copy to clipboard",
|
||||||
"source_copied": "Source copied to clipboard",
|
"source_copied": "Source copied to clipboard",
|
||||||
"attachments": "Attachments",
|
"attachments": "Attachments",
|
||||||
|
"important": "Important",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"from": "From",
|
"from": "From",
|
||||||
"to": "To",
|
"to": "To",
|
||||||
@@ -90,7 +101,34 @@
|
|||||||
"external_content_warning": "Images and external content have been blocked",
|
"external_content_warning": "Images and external content have been blocked",
|
||||||
"load_external_content": "Load images",
|
"load_external_content": "Load images",
|
||||||
"trust_sender": "Always trust this sender",
|
"trust_sender": "Always trust this sender",
|
||||||
|
"back_to_list": "Back to list",
|
||||||
"message_details": "Message Details",
|
"message_details": "Message Details",
|
||||||
|
"more_reply_options": "More reply options",
|
||||||
|
"set_color": "Set color",
|
||||||
|
"more_actions": "More actions",
|
||||||
|
"remove_color": "Remove color",
|
||||||
|
"more_count": "+{count} more",
|
||||||
|
"characters_count": "{count} characters",
|
||||||
|
"quick_reply_placeholder": "Write a quick reply...",
|
||||||
|
"more_options": "More options",
|
||||||
|
"sending": "Sending...",
|
||||||
|
"security_authentication": "Security & Authentication",
|
||||||
|
"technical_details": "Technical Details",
|
||||||
|
"message_id_label": "Message-ID:",
|
||||||
|
"reply_to_label": "Reply-To:",
|
||||||
|
"delivery_time_label": "Delivery time:",
|
||||||
|
"conversation_part_label": "Part of conversation:",
|
||||||
|
"previous_messages": "{count} previous message",
|
||||||
|
"previous_messages_plural": "{count} previous messages",
|
||||||
|
"time": {
|
||||||
|
"day": "day",
|
||||||
|
"days": "days",
|
||||||
|
"hour": "hour",
|
||||||
|
"hours": "hours",
|
||||||
|
"minute": "minute",
|
||||||
|
"minutes": "minutes"
|
||||||
|
},
|
||||||
|
"unknown_sender": "Unknown",
|
||||||
"authentication": {
|
"authentication": {
|
||||||
"title": "Authentication",
|
"title": "Authentication",
|
||||||
"status": {
|
"status": {
|
||||||
@@ -151,6 +189,17 @@
|
|||||||
"attach": "Attach",
|
"attach": "Attach",
|
||||||
"discard": "Discard",
|
"discard": "Discard",
|
||||||
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
|
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"draft_saved": "Draft saved",
|
||||||
|
"save_failed": "Failed to save",
|
||||||
|
"to_placeholder": "Recipient email addresses (comma separated)",
|
||||||
|
"cc_placeholder": "Cc recipients (comma separated)",
|
||||||
|
"bcc_placeholder": "Bcc recipients (comma separated)",
|
||||||
|
"subject_placeholder": "Subject",
|
||||||
|
"cc_label": "Cc:",
|
||||||
|
"bcc_label": "Bcc:",
|
||||||
|
"subject_label": "Subject:",
|
||||||
|
"file_size_kb": "KB",
|
||||||
"quote": {
|
"quote": {
|
||||||
"reply_header": "On {{date}}, {{sender}} wrote:",
|
"reply_header": "On {{date}}, {{sender}} wrote:",
|
||||||
"forward_header": "---------- Forwarded message ----------",
|
"forward_header": "---------- Forwarded message ----------",
|
||||||
@@ -176,7 +225,8 @@
|
|||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
"unknown": "Unknown"
|
"unknown": "Unknown",
|
||||||
|
"app_title": "Webmail"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"email_sent": "Email sent successfully",
|
"email_sent": "Email sent successfully",
|
||||||
@@ -213,7 +263,11 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"title": "Language",
|
"title": "Language",
|
||||||
"english": "English",
|
"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": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
@@ -241,6 +295,10 @@
|
|||||||
"dark": "Dark",
|
"dark": "Dark",
|
||||||
"system": "System"
|
"system": "System"
|
||||||
},
|
},
|
||||||
|
"language": {
|
||||||
|
"label": "Language",
|
||||||
|
"description": "Choose your preferred language"
|
||||||
|
},
|
||||||
"font_size": {
|
"font_size": {
|
||||||
"label": "Font Size",
|
"label": "Font Size",
|
||||||
"description": "Adjust text size for better readability",
|
"description": "Adjust text size for better readability",
|
||||||
|
|||||||
+61
-3
@@ -21,6 +21,7 @@
|
|||||||
"remove_from_history": "Supprimer de l'historique"
|
"remove_from_history": "Supprimer de l'historique"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
"close": "Fermer",
|
||||||
"compose": "Composer",
|
"compose": "Composer",
|
||||||
"search_placeholder": "Rechercher un email...",
|
"search_placeholder": "Rechercher un email...",
|
||||||
"storage": "Stockage",
|
"storage": "Stockage",
|
||||||
@@ -34,6 +35,9 @@
|
|||||||
"dark": "Mode sombre",
|
"dark": "Mode sombre",
|
||||||
"system": "Thème système"
|
"system": "Thème système"
|
||||||
},
|
},
|
||||||
|
"language": {
|
||||||
|
"title": "Langue"
|
||||||
|
},
|
||||||
"mailboxes": {
|
"mailboxes": {
|
||||||
"inbox": "Boîte de réception",
|
"inbox": "Boîte de réception",
|
||||||
"sent": "Envoyés",
|
"sent": "Envoyés",
|
||||||
@@ -46,7 +50,9 @@
|
|||||||
"important": "Important"
|
"important": "Important"
|
||||||
},
|
},
|
||||||
"expand": "Développer",
|
"expand": "Développer",
|
||||||
"collapse": "Réduire"
|
"collapse": "Réduire",
|
||||||
|
"expand_tooltip": "Développer",
|
||||||
|
"collapse_tooltip": "Réduire"
|
||||||
},
|
},
|
||||||
"email_list": {
|
"email_list": {
|
||||||
"no_emails": "Aucun email",
|
"no_emails": "Aucun email",
|
||||||
@@ -62,7 +68,11 @@
|
|||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "Aucun email sélectionné",
|
"no_email_selected": "Aucun email sélectionné",
|
||||||
"no_email_description": "Sélectionnez un email dans la liste pour le voir ici",
|
"no_email_description": "Sélectionnez un email dans la liste pour le voir ici",
|
||||||
|
"no_conversation_selected": "Aucune conversation sélectionnée",
|
||||||
|
"no_conversation_description": "Choisissez une conversation dans la liste pour la lire ici",
|
||||||
"no_subject": "(Sans objet)",
|
"no_subject": "(Sans objet)",
|
||||||
|
"loading_email": "Chargement de l'email...",
|
||||||
|
"loading": "Chargement...",
|
||||||
"reply": "Répondre",
|
"reply": "Répondre",
|
||||||
"reply_all": "Répondre à tous",
|
"reply_all": "Répondre à tous",
|
||||||
"forward": "Transférer",
|
"forward": "Transférer",
|
||||||
@@ -78,6 +88,7 @@
|
|||||||
"copy_source": "Copier dans le presse-papiers",
|
"copy_source": "Copier dans le presse-papiers",
|
||||||
"source_copied": "Source copiée dans le presse-papiers",
|
"source_copied": "Source copiée dans le presse-papiers",
|
||||||
"attachments": "Pièces jointes",
|
"attachments": "Pièces jointes",
|
||||||
|
"important": "Important",
|
||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
"from": "De",
|
"from": "De",
|
||||||
"to": "À",
|
"to": "À",
|
||||||
@@ -90,7 +101,34 @@
|
|||||||
"external_content_warning": "Les images et le contenu externe ont été bloqués",
|
"external_content_warning": "Les images et le contenu externe ont été bloqués",
|
||||||
"load_external_content": "Charger les images",
|
"load_external_content": "Charger les images",
|
||||||
"trust_sender": "Toujours faire confiance à cet expéditeur",
|
"trust_sender": "Toujours faire confiance à cet expéditeur",
|
||||||
|
"back_to_list": "Retour à la liste",
|
||||||
"message_details": "Détails du message",
|
"message_details": "Détails du message",
|
||||||
|
"more_reply_options": "Plus d'options de réponse",
|
||||||
|
"set_color": "Définir la couleur",
|
||||||
|
"more_actions": "Plus d'actions",
|
||||||
|
"remove_color": "Retirer la couleur",
|
||||||
|
"more_count": "+{count} de plus",
|
||||||
|
"characters_count": "{count} caractères",
|
||||||
|
"quick_reply_placeholder": "Écrivez une réponse rapide...",
|
||||||
|
"more_options": "Plus d'options",
|
||||||
|
"sending": "Envoi en cours...",
|
||||||
|
"security_authentication": "Sécurité et authentification",
|
||||||
|
"technical_details": "Détails techniques",
|
||||||
|
"message_id_label": "ID du message :",
|
||||||
|
"reply_to_label": "Répondre à :",
|
||||||
|
"delivery_time_label": "Temps de livraison :",
|
||||||
|
"conversation_part_label": "Partie de la conversation :",
|
||||||
|
"previous_messages": "{count} message précédent",
|
||||||
|
"previous_messages_plural": "{count} messages précédents",
|
||||||
|
"time": {
|
||||||
|
"day": "jour",
|
||||||
|
"days": "jours",
|
||||||
|
"hour": "heure",
|
||||||
|
"hours": "heures",
|
||||||
|
"minute": "minute",
|
||||||
|
"minutes": "minutes"
|
||||||
|
},
|
||||||
|
"unknown_sender": "Inconnu",
|
||||||
"authentication": {
|
"authentication": {
|
||||||
"title": "Authentification",
|
"title": "Authentification",
|
||||||
"status": {
|
"status": {
|
||||||
@@ -151,6 +189,17 @@
|
|||||||
"attach": "Joindre",
|
"attach": "Joindre",
|
||||||
"discard": "Supprimer",
|
"discard": "Supprimer",
|
||||||
"discard_draft_confirm": "Vous avez des modifications non enregistrées. Voulez-vous supprimer ce brouillon ?",
|
"discard_draft_confirm": "Vous avez des modifications non enregistrées. Voulez-vous supprimer ce brouillon ?",
|
||||||
|
"saving": "Enregistrement...",
|
||||||
|
"draft_saved": "Brouillon enregistré",
|
||||||
|
"save_failed": "Échec de l'enregistrement",
|
||||||
|
"to_placeholder": "Adresses email des destinataires (séparées par des virgules)",
|
||||||
|
"cc_placeholder": "Destinataires en copie (séparés par des virgules)",
|
||||||
|
"bcc_placeholder": "Destinataires en copie cachée (séparés par des virgules)",
|
||||||
|
"subject_placeholder": "Objet",
|
||||||
|
"cc_label": "Cc :",
|
||||||
|
"bcc_label": "Cci :",
|
||||||
|
"subject_label": "Objet :",
|
||||||
|
"file_size_kb": "Ko",
|
||||||
"quote": {
|
"quote": {
|
||||||
"reply_header": "Le {{date}}, {{sender}} a écrit :",
|
"reply_header": "Le {{date}}, {{sender}} a écrit :",
|
||||||
"forward_header": "---------- Message transféré ----------",
|
"forward_header": "---------- Message transféré ----------",
|
||||||
@@ -176,7 +225,8 @@
|
|||||||
"logout": "Déconnexion",
|
"logout": "Déconnexion",
|
||||||
"yes": "Oui",
|
"yes": "Oui",
|
||||||
"no": "Non",
|
"no": "Non",
|
||||||
"unknown": "Inconnu"
|
"unknown": "Inconnu",
|
||||||
|
"app_title": "Webmail"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"email_sent": "Email envoyé avec succès",
|
"email_sent": "Email envoyé avec succès",
|
||||||
@@ -213,7 +263,11 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"title": "Langue",
|
"title": "Langue",
|
||||||
"english": "English",
|
"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": {
|
"settings": {
|
||||||
"title": "Paramètres",
|
"title": "Paramètres",
|
||||||
@@ -241,6 +295,10 @@
|
|||||||
"dark": "Sombre",
|
"dark": "Sombre",
|
||||||
"system": "Système"
|
"system": "Système"
|
||||||
},
|
},
|
||||||
|
"language": {
|
||||||
|
"label": "Langue",
|
||||||
|
"description": "Choisissez votre langue préférée"
|
||||||
|
},
|
||||||
"font_size": {
|
"font_size": {
|
||||||
"label": "Taille de police",
|
"label": "Taille de police",
|
||||||
"description": "Ajustez la taille du texte pour une meilleure lisibilité",
|
"description": "Ajustez la taille du texte pour une meilleure lisibilité",
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import createMiddleware from 'next-intl/middleware';
|
import createIntlMiddleware from 'next-intl/middleware';
|
||||||
import { locales, defaultLocale } from './i18n/request';
|
import { routing } from './i18n/routing';
|
||||||
|
|
||||||
export default createMiddleware({
|
export default createIntlMiddleware(routing);
|
||||||
locales,
|
|
||||||
defaultLocale,
|
|
||||||
localePrefix: 'always', // Always show locale in URL for consistency
|
|
||||||
localeDetection: true // Enable browser language detection
|
|
||||||
});
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
// Skip all paths that should not be internationalized
|
// Skip all paths that should not be internationalized
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ interface UIState {
|
|||||||
activeView: ActiveView;
|
activeView: ActiveView;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
|
|
||||||
|
// Tablet list visibility (auto-hide when email selected)
|
||||||
|
tabletListVisible: boolean;
|
||||||
|
|
||||||
// Device detection (hydrated client-side)
|
// Device detection (hydrated client-side)
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
isTablet: boolean;
|
isTablet: boolean;
|
||||||
@@ -18,6 +21,7 @@ interface UIState {
|
|||||||
setActiveView: (view: ActiveView) => void;
|
setActiveView: (view: ActiveView) => void;
|
||||||
setSidebarOpen: (open: boolean) => void;
|
setSidebarOpen: (open: boolean) => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
|
setTabletListVisible: (visible: boolean) => void;
|
||||||
setDeviceType: (isMobile: boolean, isTablet: boolean, isDesktop: boolean) => void;
|
setDeviceType: (isMobile: boolean, isTablet: boolean, isDesktop: boolean) => void;
|
||||||
|
|
||||||
// Navigation helpers
|
// Navigation helpers
|
||||||
@@ -30,6 +34,7 @@ export const useUIStore = create<UIState>((set, get) => ({
|
|||||||
// Initial state (SSR-safe defaults)
|
// Initial state (SSR-safe defaults)
|
||||||
activeView: "list",
|
activeView: "list",
|
||||||
sidebarOpen: false,
|
sidebarOpen: false,
|
||||||
|
tabletListVisible: true,
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
isTablet: false,
|
isTablet: false,
|
||||||
isDesktop: true,
|
isDesktop: true,
|
||||||
@@ -41,6 +46,8 @@ export const useUIStore = create<UIState>((set, get) => ({
|
|||||||
|
|
||||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||||
|
|
||||||
|
setTabletListVisible: (visible) => set({ tabletListVisible: visible }),
|
||||||
|
|
||||||
setDeviceType: (isMobile, isTablet, isDesktop) =>
|
setDeviceType: (isMobile, isTablet, isDesktop) =>
|
||||||
set({ isMobile, isTablet, isDesktop }),
|
set({ isMobile, isTablet, isDesktop }),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user