feat(i18n): Complete internationalization coverage

Replaces all remaining hardcoded strings with translation keys to ensure full localization support across the application. This allows users to experience the entire interface in their preferred language without any English fallbacks.
This commit is contained in:
Matthieu MALVACHE
2026-01-08 04:24:58 +01:00
parent 5613f66bd7
commit 91b8a42fdd
6 changed files with 144 additions and 49 deletions
+6 -5
View File
@@ -31,6 +31,7 @@ import { DragDropProvider } from "@/contexts/drag-drop-context";
export default function Home() {
const router = useRouter();
const t = useTranslations();
const tCommon = useTranslations('common');
const [showComposer, setShowComposer] = useState(false);
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [initialCheckDone, setInitialCheckDone] = useState(false);
@@ -194,7 +195,7 @@ export default function Home() {
// Update page title based on context
useEffect(() => {
let title = "Webmail";
let title = tCommon('app_title');
if (showComposer) {
// Composing email
@@ -204,11 +205,11 @@ export default function Home() {
replyAll: t('email_composer.reply_all'),
forward: t('email_composer.forward'),
}[composerMode] || t('email_composer.new_message');
title = `${modeText} - Webmail`;
title = `${modeText} - ${tCommon('app_title')}`;
} else if (selectedEmail) {
// Reading email
const subject = selectedEmail.subject || t('email_viewer.no_subject');
title = `${subject} - Webmail`;
title = `${subject} - ${tCommon('app_title')}`;
} else if (selectedMailbox && mailboxes.length > 0) {
// Mailbox view
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
@@ -216,8 +217,8 @@ export default function Home() {
const mailboxName = mailbox.name;
const unreadCount = mailbox.unreadEmails || 0;
title = unreadCount > 0
? `${mailboxName} (${unreadCount}) - Webmail`
: `${mailboxName} - Webmail`;
? `${mailboxName} (${unreadCount}) - ${tCommon('app_title')}`
: `${mailboxName} - ${tCommon('app_title')}`;
}
}
+12 -12
View File
@@ -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)}
+32 -27
View File
@@ -138,6 +138,7 @@ export function EmailViewer({
}: 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);
@@ -527,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>
);
@@ -548,7 +549,7 @@ 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>
)}
@@ -607,7 +608,7 @@ export function EmailViewer({
{isLoading && (
<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 */}
@@ -627,7 +628,7 @@ export function EmailViewer({
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>
@@ -688,7 +689,7 @@ export function EmailViewer({
<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",
@@ -733,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>
@@ -748,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>
@@ -787,7 +788,7 @@ export function EmailViewer({
<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">
@@ -807,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>
@@ -833,7 +834,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">
<ShieldCheck className="w-3.5 h-3.5" />
Security & Authentication
{t('security_authentication')}
</h3>
</div>
<div className="bg-background p-4 space-y-3">
@@ -1024,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">
@@ -1034,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>
@@ -1048,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">
@@ -1067,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>
@@ -1088,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>
@@ -1136,7 +1141,7 @@ export function EmailViewer({
<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">
@@ -1334,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",
@@ -1348,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
@@ -1360,7 +1365,7 @@ export function EmailViewer({
}}
disabled={isSendingQuickReply}
>
Cancel
{tCommon('cancel')}
</Button>
<Button
variant="ghost"
@@ -1370,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"
@@ -1393,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>
+2 -1
View File
@@ -95,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);
@@ -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" />
+46 -2
View File
@@ -50,7 +50,9 @@
"important": "Important"
},
"expand": "Expand",
"collapse": "Collapse"
"collapse": "Collapse",
"expand_tooltip": "Expand",
"collapse_tooltip": "Collapse"
},
"email_list": {
"no_emails": "No emails",
@@ -66,7 +68,11 @@
"email_viewer": {
"no_email_selected": "No email selected",
"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)",
"loading_email": "Loading email...",
"loading": "Loading...",
"reply": "Reply",
"reply_all": "Reply All",
"forward": "Forward",
@@ -97,6 +103,32 @@
"trust_sender": "Always trust this sender",
"back_to_list": "Back to list",
"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": {
"title": "Authentication",
"status": {
@@ -157,6 +189,17 @@
"attach": "Attach",
"discard": "Discard",
"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": {
"reply_header": "On {{date}}, {{sender}} wrote:",
"forward_header": "---------- Forwarded message ----------",
@@ -182,7 +225,8 @@
"logout": "Logout",
"yes": "Yes",
"no": "No",
"unknown": "Unknown"
"unknown": "Unknown",
"app_title": "Webmail"
},
"notifications": {
"email_sent": "Email sent successfully",
+46 -2
View File
@@ -50,7 +50,9 @@
"important": "Important"
},
"expand": "Développer",
"collapse": "Réduire"
"collapse": "Réduire",
"expand_tooltip": "Développer",
"collapse_tooltip": "Réduire"
},
"email_list": {
"no_emails": "Aucun email",
@@ -66,7 +68,11 @@
"email_viewer": {
"no_email_selected": "Aucun email sélectionné",
"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)",
"loading_email": "Chargement de l'email...",
"loading": "Chargement...",
"reply": "Répondre",
"reply_all": "Répondre à tous",
"forward": "Transférer",
@@ -97,6 +103,32 @@
"trust_sender": "Toujours faire confiance à cet expéditeur",
"back_to_list": "Retour à la liste",
"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": {
"title": "Authentification",
"status": {
@@ -157,6 +189,17 @@
"attach": "Joindre",
"discard": "Supprimer",
"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": {
"reply_header": "Le {{date}}, {{sender}} a écrit :",
"forward_header": "---------- Message transféré ----------",
@@ -182,7 +225,8 @@
"logout": "Déconnexion",
"yes": "Oui",
"no": "Non",
"unknown": "Inconnu"
"unknown": "Inconnu",
"app_title": "Webmail"
},
"notifications": {
"email_sent": "Email envoyé avec succès",