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:
@@ -315,19 +315,19 @@ export function EmailComposer({
|
||||
{saveStatus === 'saving' && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Save className="w-3 h-3 animate-pulse" />
|
||||
<span>Saving...</span>
|
||||
<span>{t('saving')}</span>
|
||||
</div>
|
||||
)}
|
||||
{saveStatus === 'saved' && (
|
||||
<div className="flex items-center gap-1 text-xs text-green-600">
|
||||
<Check className="w-3 h-3" />
|
||||
<span>Draft saved</span>
|
||||
<span>{t('draft_saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<div className="flex items-center gap-1 text-xs text-red-600">
|
||||
<X className="w-3 h-3" />
|
||||
<span>Failed to save</span>
|
||||
<span>{t('save_failed')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -366,7 +366,7 @@ export function EmailComposer({
|
||||
<span className="text-sm text-muted-foreground w-16">{t('to')}:</span>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Recipient email addresses (comma separated)"
|
||||
placeholder={t('to_placeholder')}
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className="flex-1 border-0 focus-visible:ring-0"
|
||||
@@ -393,10 +393,10 @@ export function EmailComposer({
|
||||
|
||||
{showCc && (
|
||||
<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
|
||||
type="email"
|
||||
placeholder="Cc recipients (comma separated)"
|
||||
placeholder={t('cc_placeholder')}
|
||||
value={cc}
|
||||
onChange={(e) => setCc(e.target.value)}
|
||||
className="flex-1 border-0 focus-visible:ring-0"
|
||||
@@ -406,10 +406,10 @@ export function EmailComposer({
|
||||
|
||||
{showBcc && (
|
||||
<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
|
||||
type="email"
|
||||
placeholder="Bcc recipients (comma separated)"
|
||||
placeholder={t('bcc_placeholder')}
|
||||
value={bcc}
|
||||
onChange={(e) => setBcc(e.target.value)}
|
||||
className="flex-1 border-0 focus-visible:ring-0"
|
||||
@@ -418,10 +418,10 @@ export function EmailComposer({
|
||||
)}
|
||||
|
||||
<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
|
||||
type="text"
|
||||
placeholder="Subject"
|
||||
placeholder={t('subject_placeholder')}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
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">
|
||||
<textarea
|
||||
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}
|
||||
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="text-xs text-muted-foreground">
|
||||
({(att.file.size / 1024).toFixed(1)} KB)
|
||||
({(att.file.size / 1024).toFixed(1)} {t('file_size_kb')})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeAttachment(index)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
MoreVertical,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronLeft,
|
||||
Download,
|
||||
Paperclip,
|
||||
Mail,
|
||||
@@ -48,6 +49,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
|
||||
interface EmailViewerProps {
|
||||
email: Email | null;
|
||||
@@ -62,6 +65,7 @@ interface EmailViewerProps {
|
||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
|
||||
onQuickReply?: (body: string) => Promise<void>;
|
||||
onBack?: () => void;
|
||||
currentUserEmail?: string;
|
||||
currentUserName?: string;
|
||||
className?: string;
|
||||
@@ -127,15 +131,21 @@ export function EmailViewer({
|
||||
onSetColorTag,
|
||||
onDownloadAttachment,
|
||||
onQuickReply,
|
||||
onBack,
|
||||
currentUserEmail,
|
||||
currentUserName,
|
||||
className,
|
||||
}: EmailViewerProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tCommon = useTranslations('common');
|
||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
||||
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
||||
|
||||
// Tablet list visibility
|
||||
const { isTablet } = useDeviceDetection();
|
||||
const { tabletListVisible } = useUIStore();
|
||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||
const [allowExternalContent, setAllowExternalContent] = 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)}>
|
||||
{/* Loading Header Skeleton - gentler animation */}
|
||||
<div className="bg-background border-b border-border">
|
||||
<div className="px-4 md:px-6 py-3 md:py-4">
|
||||
<div className="flex items-start justify-between gap-2 md:gap-4">
|
||||
<div className="flex-1 min-w-0 space-y-2 md:space-y-3">
|
||||
<div className="h-6 md:h-8 bg-muted/60 rounded-md w-3/4"></div>
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className="h-3 md:h-4 bg-muted/60 rounded w-24 md:w-32"></div>
|
||||
<div className="h-3 md:h-4 bg-muted/60 rounded w-16 md:w-24"></div>
|
||||
<div className="px-4 lg:px-6 py-3 lg:py-4">
|
||||
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||
<div className="flex-1 min-w-0 space-y-2 lg:space-y-3">
|
||||
<div className="h-6 lg:h-8 bg-muted/60 rounded-md w-3/4"></div>
|
||||
<div className="flex items-center gap-2 lg:gap-3">
|
||||
<div className="h-3 lg:h-4 bg-muted/60 rounded w-24 lg:w-32"></div>
|
||||
<div className="h-3 lg:h-4 bg-muted/60 rounded w-16 lg:w-24"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
<div className="h-8 w-8 md:w-20 bg-muted/60 rounded"></div>
|
||||
<div className="h-8 w-8 bg-muted/60 rounded hidden md:block"></div>
|
||||
<div className="flex items-center gap-1 lg:gap-2">
|
||||
<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 lg:block"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading Sender Info Skeleton */}
|
||||
<div className="px-4 md:px-6 pb-3 md:pb-4">
|
||||
<div className="flex items-start gap-3 md:gap-4">
|
||||
<div className="w-10 h-10 md:w-12 md:h-12 bg-muted/60 rounded-full"></div>
|
||||
<div className="px-4 lg:px-6 pb-3 lg:pb-4">
|
||||
<div className="flex items-start gap-3 lg:gap-4">
|
||||
<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="h-4 bg-muted/60 rounded w-48"></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">
|
||||
<Mail className="w-10 h-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">No conversation selected</h3>
|
||||
<p className="text-muted-foreground">Choose a conversation from the list to read it here</p>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
||||
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
||||
</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="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" />
|
||||
<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>
|
||||
)}
|
||||
{/* Modern Header Section */}
|
||||
<div className="bg-background border-b border-border">
|
||||
{/* Subject Bar */}
|
||||
<div className="px-4 md:px-6 py-3 md:py-4">
|
||||
<div className="flex items-start justify-between gap-2 md:gap-4">
|
||||
{/* Subject Bar - sticky on mobile/tablet for quick actions */}
|
||||
<div className={cn(
|
||||
"bg-background border-b border-border",
|
||||
"max-lg:sticky max-lg:top-0 max-lg:z-10"
|
||||
)}>
|
||||
<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">
|
||||
<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)"}
|
||||
</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">
|
||||
<span className="flex items-center gap-1 md:gap-1.5 whitespace-nowrap">
|
||||
<Clock className="w-3.5 h-3.5 md:w-4 md:h-4" />
|
||||
<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 lg:gap-1.5 whitespace-nowrap">
|
||||
<Clock className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
|
||||
{new Date(email.receivedAt).toLocaleString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
@@ -565,14 +589,14 @@ export function EmailViewer({
|
||||
})}
|
||||
</span>
|
||||
{email.hasAttachment && (
|
||||
<span className="flex items-center gap-1 md:gap-1.5 whitespace-nowrap">
|
||||
<Paperclip className="w-3.5 h-3.5 md:w-4 md:h-4" />
|
||||
<span className="hidden md:inline">Attachments</span>
|
||||
<span className="flex items-center gap-1 lg:gap-1.5 whitespace-nowrap">
|
||||
<Paperclip className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
|
||||
<span className="hidden lg:inline">{t('attachments')}</span>
|
||||
</span>
|
||||
)}
|
||||
{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">
|
||||
Important
|
||||
<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">
|
||||
{t('important')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -582,29 +606,29 @@ export function EmailViewer({
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{/* Loading indicator */}
|
||||
{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" />
|
||||
<span className="text-xs">Loading...</span>
|
||||
<span className="text-xs">{t('loading')}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Primary Reply Button */}
|
||||
<Button
|
||||
onClick={onReply}
|
||||
size="sm"
|
||||
className="mr-1 h-8 md:h-9"
|
||||
className="mr-1 h-8 lg:h-9"
|
||||
title="Reply"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Reply Options Dropdown - hidden on mobile */}
|
||||
<div className="relative group mr-3 hidden md:block">
|
||||
{/* Reply Options Dropdown - hidden on mobile/tablet */}
|
||||
<div className="relative group mr-3 hidden lg:block">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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" />
|
||||
</Button>
|
||||
@@ -626,13 +650,13 @@ export function EmailViewer({
|
||||
</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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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"
|
||||
>
|
||||
<Archive className="w-4 h-4 text-muted-foreground" />
|
||||
@@ -650,7 +674,7 @@ export function EmailViewer({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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"}
|
||||
>
|
||||
<Star className={cn(
|
||||
@@ -659,13 +683,13 @@ export function EmailViewer({
|
||||
)} />
|
||||
</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 */}
|
||||
<div className="relative group hidden md:block">
|
||||
{/* Compact Dynamic Color Picker - hidden on mobile/tablet */}
|
||||
<div className="relative group hidden lg:block">
|
||||
<button
|
||||
className="h-8 w-8 rounded hover:bg-muted flex items-center justify-center"
|
||||
title="Set color"
|
||||
title={t('set_color')}
|
||||
>
|
||||
<Circle className={cn(
|
||||
"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"
|
||||
title="Remove color"
|
||||
title={t('remove_color')}
|
||||
>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -725,7 +749,7 @@ export function EmailViewer({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 hover:bg-muted"
|
||||
title="More actions"
|
||||
title={t('more_actions')}
|
||||
>
|
||||
<MoreVertical className="w-4 h-4 text-muted-foreground" />
|
||||
</Button>
|
||||
@@ -749,21 +773,22 @@ export function EmailViewer({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sender Info */}
|
||||
<div className="px-4 md:px-6 pb-3 md:pb-4">
|
||||
<div className="flex items-start gap-3 md:gap-4">
|
||||
{/* Sender Info - Desktop only (hidden on mobile/tablet, they see it in scrollable content) */}
|
||||
<div className="hidden lg:block bg-background border-b border-border px-6 py-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
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 items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-foreground">
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
{sender?.name || sender?.email || t('unknown_sender')}
|
||||
</span>
|
||||
{sender?.email && sender?.name && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
@@ -783,7 +808,7 @@ export function EmailViewer({
|
||||
onClick={() => setShowFullHeaders(!showFullHeaders)}
|
||||
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>
|
||||
)}
|
||||
</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">
|
||||
<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" />
|
||||
Security & Authentication
|
||||
{t('security_authentication')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="bg-background p-4 space-y-3">
|
||||
{/* Authentication Results */}
|
||||
{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 */}
|
||||
{email.authenticationResults.spf && (
|
||||
<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">
|
||||
<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" />
|
||||
Technical Details
|
||||
{t('technical_details')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="bg-background p-4">
|
||||
@@ -1010,7 +1035,7 @@ export function EmailViewer({
|
||||
<div className="flex items-start gap-2">
|
||||
<Hash className="w-3.5 h-3.5 text-muted-foreground mt-0.5" />
|
||||
<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">
|
||||
{email.messageId}
|
||||
</div>
|
||||
@@ -1024,7 +1049,7 @@ export function EmailViewer({
|
||||
<div className="flex items-start gap-2">
|
||||
<Mail className="w-3.5 h-3.5 text-muted-foreground mt-0.5" />
|
||||
<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">
|
||||
{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">
|
||||
@@ -1043,16 +1068,20 @@ export function EmailViewer({
|
||||
<div className="flex items-start gap-2">
|
||||
<Clock className="w-3.5 h-3.5 text-muted-foreground mt-0.5" />
|
||||
<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">
|
||||
{(() => {
|
||||
const diff = Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime());
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ${hours % 24} hour${hours % 24 !== 1 ? 's' : ''}`;
|
||||
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ${minutes % 60} minute${minutes % 60 !== 1 ? 's' : ''}`;
|
||||
return `${minutes} minute${minutes > 1 ? 's' : ''}`;
|
||||
const dayUnit = days > 1 ? t('time.days') : t('time.day');
|
||||
const hourUnit = (hours % 24) > 1 ? t('time.hours') : t('time.hour');
|
||||
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>
|
||||
@@ -1064,9 +1093,9 @@ export function EmailViewer({
|
||||
<div className="flex items-start gap-2">
|
||||
<List className="w-3.5 h-3.5 text-muted-foreground mt-0.5" />
|
||||
<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">
|
||||
{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>
|
||||
@@ -1096,12 +1125,54 @@ export function EmailViewer({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Content Area */}
|
||||
<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 */}
|
||||
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
||||
<div className="border-b border-border">
|
||||
@@ -1268,7 +1339,7 @@ export function EmailViewer({
|
||||
value={quickReplyText}
|
||||
onChange={(e) => setQuickReplyText(e.target.value)}
|
||||
onFocus={() => setIsQuickReplyFocused(true)}
|
||||
placeholder="Write a quick reply..."
|
||||
placeholder={t('quick_reply_placeholder')}
|
||||
className={cn(
|
||||
"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",
|
||||
@@ -1282,7 +1353,7 @@ export function EmailViewer({
|
||||
{(isQuickReplyFocused || quickReplyText) && (
|
||||
<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">
|
||||
{quickReplyText.length > 0 && `${quickReplyText.length} characters`}
|
||||
{quickReplyText.length > 0 && t('characters_count', { count: quickReplyText.length })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -1294,7 +1365,7 @@ export function EmailViewer({
|
||||
}}
|
||||
disabled={isSendingQuickReply}
|
||||
>
|
||||
Cancel
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1304,7 +1375,7 @@ export function EmailViewer({
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4 mr-1" />
|
||||
More options
|
||||
{t('more_options')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1327,12 +1398,12 @@ export function EmailViewer({
|
||||
{isSendingQuickReply ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
Sending...
|
||||
{t('sending')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Reply className="w-4 h-4 mr-1" />
|
||||
Send
|
||||
{t('send')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function MobileHeader({
|
||||
<header
|
||||
className={cn(
|
||||
"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
|
||||
)}
|
||||
>
|
||||
@@ -115,7 +115,7 @@ export function MobileViewerHeader({
|
||||
<header
|
||||
className={cn(
|
||||
"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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -37,6 +37,7 @@ interface SidebarProps {
|
||||
onMailboxSelect?: (mailboxId: string) => void;
|
||||
onCompose?: () => void;
|
||||
onLogout?: () => void;
|
||||
onSidebarClose?: () => void;
|
||||
onSearch?: (query: string) => void;
|
||||
onClearSearch?: () => void;
|
||||
activeSearchQuery?: string;
|
||||
@@ -94,6 +95,7 @@ function MailboxTreeItem({
|
||||
onToggleExpand: (id: string) => void;
|
||||
isCollapsed: boolean;
|
||||
}) {
|
||||
const t = useTranslations('sidebar');
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedFolders.has(node.id);
|
||||
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
|
||||
@@ -111,12 +113,11 @@ function MailboxTreeItem({
|
||||
<div
|
||||
{...(globalDragging ? dropHandlers : {})}
|
||||
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
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted text-foreground",
|
||||
node.depth === 0 && "font-medium", // Root folders are slightly bolder
|
||||
// Drop target visual feedback
|
||||
node.depth === 0 && "font-medium",
|
||||
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset",
|
||||
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"
|
||||
)}
|
||||
style={{ marginLeft: indentPixels }}
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
title={isExpanded ? t('collapse_tooltip') : t('expand_tooltip')}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
@@ -148,7 +149,7 @@ function MailboxTreeItem({
|
||||
onClick={() => !isVirtualNode && onMailboxSelect?.(node.id)}
|
||||
disabled={isVirtualNode}
|
||||
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",
|
||||
isVirtualNode && "cursor-default"
|
||||
)}
|
||||
@@ -208,6 +209,7 @@ export function Sidebar({
|
||||
onMailboxSelect,
|
||||
onCompose,
|
||||
onLogout,
|
||||
onSidebarClose,
|
||||
onSearch,
|
||||
onClearSearch,
|
||||
activeSearchQuery = "",
|
||||
@@ -225,7 +227,6 @@ export function Sidebar({
|
||||
useEffect(() => {
|
||||
setSearchQuery(activeSearchQuery);
|
||||
}, [activeSearchQuery]);
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
// Load expanded folders from localStorage on mount
|
||||
@@ -313,21 +314,36 @@ export function Sidebar({
|
||||
className={cn(
|
||||
"relative flex flex-col h-full border-r transition-all duration-300 overflow-hidden",
|
||||
"bg-secondary border-border",
|
||||
isCollapsed ? "w-16" : "w-64",
|
||||
"max-lg:w-full",
|
||||
isCollapsed ? "lg:w-16" : "lg:w-64",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* 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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="hidden lg:flex"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<Button onClick={onCompose} className="ml-2 flex-1">
|
||||
<Button onClick={onCompose} className="flex-1">
|
||||
<PenSquare className="w-4 h-4 mr-2" />
|
||||
{t("compose")}
|
||||
</Button>
|
||||
@@ -421,7 +437,7 @@ export function Sidebar({
|
||||
<div className="border-t border-border mt-2 pt-2">
|
||||
{/* Settings */}
|
||||
<button
|
||||
onClick={() => router.push(`/${params.locale}/settings`)}
|
||||
onClick={() => router.push('/settings')}
|
||||
className="w-full px-4 py-2 flex items-center justify-between hover:bg-muted transition-colors text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,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 { useThemeStore } from '@/stores/theme-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
|
||||
export function AppearanceSettings() {
|
||||
@@ -25,6 +26,11 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Language */}
|
||||
<SettingItem label={t('language.label')} description={t('language.description')}>
|
||||
<LanguageSwitcher />
|
||||
</SettingItem>
|
||||
|
||||
{/* Font Size */}
|
||||
<SettingItem label={t('font_size.label')} description={t('font_size.description')}>
|
||||
<RadioGroup
|
||||
|
||||
@@ -1,46 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { locales } from '@/i18n/request';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
|
||||
export function LanguageSwitcher({ className }: { className?: string }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
const currentLocale = useLocale();
|
||||
const t = useTranslations('language');
|
||||
const currentLocale = params.locale as string;
|
||||
const setLocale = useLocaleStore((state) => state.setLocale);
|
||||
|
||||
const handleLanguageChange = (newLocale: string) => {
|
||||
// Get the path without the locale prefix
|
||||
const pathWithoutLocale = pathname.replace(`/${currentLocale}`, '');
|
||||
if (newLocale === currentLocale) return;
|
||||
|
||||
// Navigate to the same page with the new locale
|
||||
router.push(`/${newLocale}${pathWithoutLocale}`);
|
||||
// Update locale in store (persisted to localStorage via Zustand)
|
||||
// IntlProvider handles the translation switch
|
||||
setLocale(newLocale);
|
||||
};
|
||||
|
||||
const languages = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1 p-1 bg-muted rounded-lg", className)}>
|
||||
{locales.map((locale) => (
|
||||
<div
|
||||
className={cn("flex gap-2", className)}
|
||||
role="radiogroup"
|
||||
aria-label={t('select_language')}
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={locale}
|
||||
onClick={() => handleLanguageChange(locale)}
|
||||
key={lang.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={currentLocale === lang.value}
|
||||
aria-label={t(lang.value === 'en' ? 'switch_to_english' : 'switch_to_french')}
|
||||
onClick={() => handleLanguageChange(lang.value)}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded transition-all text-xs",
|
||||
"text-foreground",
|
||||
currentLocale === locale
|
||||
? "bg-background shadow-sm font-medium"
|
||||
: "hover:bg-accent/50"
|
||||
"px-3 py-1.5 text-xs rounded transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
currentLocale === lang.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted hover:bg-accent text-foreground"
|
||||
)}
|
||||
title={t(locale === 'en' ? 'english' : 'french')}
|
||||
>
|
||||
{locale === 'en' ? '🇬🇧' : '🇫🇷'}
|
||||
<span className="hidden sm:inline">
|
||||
{locale.toUpperCase()}
|
||||
</span>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user