fix: Improve UI/UX with dark mode selection visibility and auto-select inbox on login

- Fix dark mode folder selection not visible by improving accent color contrast (#1e3a8a)
- Fix inbox not selected by default on login (auto-select primary account inbox)
- Fix email store not cleared on logout (proper state reset between sessions)
- Add settings page with appearance, email behavior, and advanced options
- Integrate mark-as-read delay setting with proper useEffect implementation
- Reset dataLoaded flag on logout to ensure fresh data load on next login

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Matthieu MALVACHE
2025-10-02 16:39:10 +02:00
co-authored by Claude
parent 1e563a8973
commit 2f454cd673
16 changed files with 1259 additions and 114 deletions
+10 -1
View File
@@ -118,7 +118,13 @@
- [x] Add smooth email loading transitions with cross-fade effect
- [x] Implement functional quick reply with auto-expand and direct send
- [ ] Implement error boundaries
- [ ] Create settings page
- [x] Create settings page
- [x] Integrate mark-as-read delay setting in email viewer
- [ ] Integrate delete action setting (trash vs permanent)
- [ ] Integrate show preview toggle in email list
- [ ] Integrate external content policy with image loading
- [ ] Integrate debug mode with console logging
- [ ] Integrate animations toggle throughout components
- [ ] Add keyboard shortcuts (j/k navigation, etc.)
- [ ] Implement drag-and-drop for emails
- [ ] Add context menus
@@ -205,4 +211,7 @@
- [x] Fixed light mode not applying properly (replaced hardcoded colors with CSS variables)
- [x] Fixed all hardcoded colors in email viewer for proper theme support
- [x] Fixed sidebar footer menu extending page height when scrolling (overflow containment)
- [x] Fixed dark mode folder selection not visible (improved accent color contrast)
- [x] Fixed inbox not selected by default on login (auto-select primary account inbox)
- [x] Fixed email store not cleared on logout (proper state reset)
+58 -15
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { useRouter, useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Sidebar } from "@/components/layout/sidebar";
@@ -9,6 +9,7 @@ import { EmailViewer } from "@/components/email/email-viewer";
import { EmailComposer } from "@/components/email/email-composer";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
export default function Home() {
const router = useRouter();
@@ -18,6 +19,7 @@ export default function Home() {
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [dataLoaded, setDataLoaded] = useState(false);
const [initialCheckDone, setInitialCheckDone] = useState(false);
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const {
emails,
@@ -80,9 +82,10 @@ export default function Home() {
});
}, [checkAuth]);
// Redirect to login if not authenticated
// Redirect to login if not authenticated and reset data loaded flag
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
setDataLoaded(false); // Reset so data is reloaded on next login
router.push(`/${params.locale}/login`);
}
}, [initialCheckDone, isAuthenticated, authLoading, router, params.locale]);
@@ -92,21 +95,19 @@ export default function Home() {
if (isAuthenticated && client && !dataLoaded) {
const loadData = async () => {
try {
// First fetch mailboxes and quota
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
await Promise.all([
fetchMailboxes(client),
fetchQuota(client)
]);
// Get the actual inbox mailbox ID
// Get the selected mailbox (should be inbox by default)
const state = useEmailStore.getState();
const inboxMailbox = state.mailboxes.find(m => m.role === 'inbox');
const selectedMailboxId = state.selectedMailbox;
if (inboxMailbox) {
// Update selected mailbox to actual inbox ID
state.selectMailbox(inboxMailbox.id);
// Now fetch emails with correct mailbox ID
await fetchEmails(client, inboxMailbox.id);
// Fetch emails for the selected mailbox
if (selectedMailboxId) {
await fetchEmails(client, selectedMailboxId);
} else {
await fetchEmails(client);
}
@@ -120,6 +121,52 @@ export default function Home() {
}
}, [isAuthenticated, client, dataLoaded, fetchMailboxes, fetchEmails, fetchQuota]);
// Handle mark-as-read with delay based on settings
useEffect(() => {
// Clear any existing timeout when email changes
if (markAsReadTimeoutRef.current) {
console.log('[Mark as Read] Clearing previous timeout');
clearTimeout(markAsReadTimeoutRef.current);
markAsReadTimeoutRef.current = null;
}
// Only set timeout if there's a selected email, it's unread, and we have a client
if (!selectedEmail || !client || selectedEmail.keywords?.$seen) {
return;
}
// Get current setting value
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
console.log('[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
if (markAsReadDelay === -1) {
// Never mark as read automatically
console.log('[Mark as Read] Never mode - email will stay unread');
} else if (markAsReadDelay === 0) {
// Mark as read instantly
console.log('[Mark as Read] Instant mode - marking as read now');
markAsRead(client, selectedEmail.id, true);
} else {
// Mark as read after delay
console.log('[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
markAsReadTimeoutRef.current = setTimeout(() => {
console.log('[Mark as Read] Timeout fired - marking as read now');
markAsRead(client, selectedEmail.id, true);
markAsReadTimeoutRef.current = null;
}, markAsReadDelay);
}
// Cleanup on unmount or when dependencies change
return () => {
if (markAsReadTimeoutRef.current) {
console.log('[Mark as Read] Cleanup - clearing timeout');
clearTimeout(markAsReadTimeoutRef.current);
markAsReadTimeoutRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedEmail?.id]);
const handleEmailSend = async (data: {
to: string[];
cc: string[];
@@ -332,11 +379,7 @@ export default function Home() {
const fullEmail = await client.getEmail(email.id, accountId);
if (fullEmail) {
selectEmail(fullEmail);
// Automatically mark as read after opening (if unread)
if (!fullEmail.keywords?.$seen) {
await markAsRead(client, fullEmail.id, true);
}
// Mark-as-read logic is now handled by useEffect
}
} catch (error) {
console.error('Failed to fetch email content:', error);
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { ArrowLeft, Settings as SettingsIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { EmailSettings } from '@/components/settings/email-settings';
import { AccountSettings } from '@/components/settings/account-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings';
import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'account' | 'advanced';
export default function SettingsPage() {
const router = useRouter();
const params = useParams();
const t = useTranslations('settings');
const [activeTab, setActiveTab] = useState<Tab>('appearance');
const tabs: { id: Tab; label: string }[] = [
{ id: 'appearance', label: t('tabs.appearance') },
{ id: 'email', label: t('tabs.email') },
{ id: 'account', label: t('tabs.account') },
{ id: 'advanced', label: t('tabs.advanced') },
];
return (
<div className="flex h-screen bg-background">
{/* Settings Sidebar */}
<div className="w-64 border-r border-border bg-secondary flex flex-col">
{/* Header */}
<div className="p-4 border-b border-border">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/${params.locale}`)}
className="w-full justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t('back_to_mail')}
</Button>
</div>
{/* Tabs */}
<div className="flex-1 overflow-y-auto py-2">
<div className="px-2 space-y-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'w-full text-left px-3 py-2 rounded text-sm transition-colors',
activeTab === tab.id
? 'bg-accent text-accent-foreground'
: 'hover:bg-muted text-foreground'
)}
>
{tab.label}
</button>
))}
</div>
</div>
</div>
{/* Settings Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto p-8">
{/* Page Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<SettingsIcon className="w-8 h-8 text-foreground" />
<h1 className="text-3xl font-semibold text-foreground">{t('title')}</h1>
</div>
</div>
{/* Active Tab Content */}
<div className="bg-card border border-border rounded-lg p-6">
{activeTab === 'appearance' && <AppearanceSettings />}
{activeTab === 'email' && <EmailSettings />}
{activeTab === 'account' && <AccountSettings />}
{activeTab === 'advanced' && <AdvancedSettings />}
</div>
</div>
</div>
</div>
);
}
+8 -2
View File
@@ -16,6 +16,11 @@
--color-muted-foreground: #64748b;
--color-accent: #dbeafe;
--color-accent-foreground: #1e40af;
/* Settings variables */
--font-size-base: 16px;
--list-item-height: 48px;
--transition-duration: 0.2s;
}
.dark {
@@ -30,8 +35,8 @@
--color-secondary-foreground: #fafafa;
--color-muted: #262626;
--color-muted-foreground: #a3a3a3;
--color-accent: #262626;
--color-accent-foreground: #fafafa;
--color-accent: #1e3a8a;
--color-accent-foreground: #dbeafe;
}
@theme inline {
@@ -58,6 +63,7 @@ body {
background-color: var(--color-background);
color: var(--color-foreground);
font-family: system-ui, -apple-system, sans-serif;
font-size: var(--font-size-base);
font-feature-settings: "rlig" 1, "calt" 1;
}
+5 -1
View File
@@ -67,8 +67,12 @@ export function EmailListItem({ email, selected, onClick }: EmailListItemProps)
isChecked && "ring-2 ring-primary/20 bg-accent/40"
)}
onClick={onClick}
style={{ minHeight: 'var(--list-item-height)' }}
>
<div className="flex items-start gap-3 px-4 py-4">
<div className="flex items-start gap-3 px-4" style={{
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
{/* Checkbox with smooth animation */}
<button
onClick={handleCheckboxClick}
+4 -92
View File
@@ -6,8 +6,6 @@ import { useParams, useRouter, usePathname } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LanguageSwitcher } from "@/components/ui/language-switcher";
import { useThemeStore } from "@/stores/theme-store";
import { locales } from "@/i18n/request";
import {
Inbox,
Send,
@@ -208,8 +206,6 @@ export function Sidebar({
const [searchQuery, setSearchQuery] = useState("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [showMenu, setShowMenu] = useState(false);
const [menuView, setMenuView] = useState<'main' | 'settings'>('main');
const { theme, setTheme, resolvedTheme } = useThemeStore();
const t = useTranslations('sidebar');
const params = useParams();
const router = useRouter();
@@ -372,9 +368,7 @@ export function Sidebar({
"transform transition-all duration-300 ease-out",
showMenu ? "-translate-y-12" : "translate-y-full"
)}>
{/* Main Menu View */}
{menuView === 'main' && (
<div className="py-2">
<div className="py-2">
{/* Storage Info */}
{quota && quota.total > 0 && (
<div className="px-4 py-2">
@@ -396,7 +390,7 @@ export function Sidebar({
<div className="border-t border-border mt-2 pt-2">
{/* Settings */}
<button
onClick={() => setMenuView('settings')}
onClick={() => router.push(`/${params.locale}/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">
@@ -417,95 +411,13 @@ export function Sidebar({
</button>
)}
</div>
</div>
)}
{/* Settings Submenu View */}
{menuView === 'settings' && (
<div className="py-2">
{/* Back Button */}
<button
onClick={() => setMenuView('main')}
className="w-full px-4 py-2 flex items-center gap-2 hover:bg-muted transition-colors text-sm border-b border-border mb-2"
>
<ChevronLeft className="w-4 h-4" />
<span className="font-medium">{t("settings")}</span>
</button>
{/* Theme */}
<div className="px-4 py-2 space-y-1">
<div className="text-xs text-muted-foreground mb-2">Theme</div>
<div className="flex gap-2">
<button
onClick={() => setTheme('light')}
className={cn(
"flex-1 px-3 py-1.5 text-xs rounded transition-colors",
theme === 'light'
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-accent"
)}
>
Light
</button>
<button
onClick={() => setTheme('dark')}
className={cn(
"flex-1 px-3 py-1.5 text-xs rounded transition-colors",
theme === 'dark'
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-accent"
)}
>
Dark
</button>
<button
onClick={() => setTheme('system')}
className={cn(
"flex-1 px-3 py-1.5 text-xs rounded transition-colors",
theme === 'system'
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-accent"
)}
>
System
</button>
</div>
</div>
{/* Language */}
<div className="px-4 py-2 space-y-1">
<div className="text-xs text-muted-foreground mb-2">Language</div>
<div className="flex gap-2">
{locales.map((locale) => (
<button
key={locale}
onClick={() => {
const pathWithoutLocale = pathname.replace(`/${params.locale}`, '');
router.push(`/${locale}${pathWithoutLocale}`);
}}
className={cn(
"flex-1 px-3 py-1.5 text-xs rounded transition-colors",
params.locale === locale
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-accent"
)}
>
{locale === 'en' ? 'English' : 'Français'}
</button>
))}
</div>
</div>
</div>
)}
</div>
</div>
{/* Menu Toggle Button */}
<div className="border-t border-border relative">
<button
onClick={() => {
setShowMenu(!showMenu);
if (!showMenu) setMenuView('main');
}}
onClick={() => setShowMenu(!showMenu)}
className={cn(
"w-full px-4 py-3 flex items-center justify-between",
"hover:bg-muted transition-colors",
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { SettingsSection, SettingItem } from './settings-section';
import { formatFileSize } from '@/lib/utils';
export function AccountSettings() {
const t = useTranslations('settings.account');
const { username, serverUrl } = useAuthStore();
const { quota } = useEmailStore();
const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Email Address */}
<SettingItem label={t('email.label')}>
<span className="text-sm text-foreground">{username || t('../../common.unknown')}</span>
</SettingItem>
{/* Server */}
<SettingItem label={t('server.label')}>
<span className="text-sm text-foreground truncate max-w-xs">
{serverUrl || t('../../common.unknown')}
</span>
</SettingItem>
{/* Storage */}
{quota && quota.total > 0 && (
<SettingItem
label={t('storage.label')}
description={t('storage.used', {
used: formatFileSize(quota.used),
total: formatFileSize(quota.total),
})}
>
<div className="flex flex-col items-end gap-1">
<span className="text-sm text-foreground">
{t('storage.percentage', { percent: quotaPercentage })}
</span>
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${quotaPercentage}%` }}
/>
</div>
</div>
</SettingItem>
)}
</SettingsSection>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useState, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
export function AdvancedSettings() {
const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common');
const { debugMode, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore();
const [showResetConfirm, setShowResetConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleExport = () => {
const settingsJson = exportSettings();
const blob = new Blob([settingsJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `webmail-settings-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleImport = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const json = event.target?.result as string;
const success = importSettings(json);
if (success) {
alert(t('../../settings.import_success'));
} else {
alert(t('../../settings.import_error'));
}
};
reader.readAsText(file);
};
const handleReset = () => {
if (showResetConfirm) {
resetToDefaults();
setShowResetConfirm(false);
alert(t('../../settings.save_success'));
} else {
setShowResetConfirm(true);
setTimeout(() => setShowResetConfirm(false), 5000);
}
};
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Debug Mode */}
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')}>
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
</SettingItem>
{/* Export Settings */}
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
<Button variant="outline" size="sm" onClick={handleExport}>
{t('export_settings.button')}
</Button>
</SettingItem>
{/* Import Settings */}
<SettingItem label={t('import_settings.label')} description={t('import_settings.description')}>
<>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleFileChange}
className="hidden"
/>
<Button variant="outline" size="sm" onClick={handleImport}>
{t('import_settings.button')}
</Button>
</>
</SettingItem>
{/* Reset Settings */}
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
<Button
variant={showResetConfirm ? 'destructive' : 'outline'}
size="sm"
onClick={handleReset}
>
{showResetConfirm ? tCommon('yes') : t('reset_settings.button')}
</Button>
</SettingItem>
</SettingsSection>
);
}
@@ -0,0 +1,65 @@
"use client";
import { useTranslations } from 'next-intl';
import { useThemeStore } from '@/stores/theme-store';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
export function AppearanceSettings() {
const t = useTranslations('settings.appearance');
const { theme, setTheme } = useThemeStore();
const { fontSize, listDensity, animationsEnabled, updateSetting } = useSettingsStore();
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Theme */}
<SettingItem label={t('theme.label')} description={t('theme.description')}>
<RadioGroup
value={theme}
onChange={(value) => setTheme(value as 'light' | 'dark' | 'system')}
options={[
{ value: 'light', label: t('theme.light') },
{ value: 'dark', label: t('theme.dark') },
{ value: 'system', label: t('theme.system') },
]}
/>
</SettingItem>
{/* Font Size */}
<SettingItem label={t('font_size.label')} description={t('font_size.description')}>
<RadioGroup
value={fontSize}
onChange={(value) => updateSetting('fontSize', value as 'small' | 'medium' | 'large')}
options={[
{ value: 'small', label: t('font_size.small') },
{ value: 'medium', label: t('font_size.medium') },
{ value: 'large', label: t('font_size.large') },
]}
/>
</SettingItem>
{/* List Density */}
<SettingItem label={t('list_density.label')} description={t('list_density.description')}>
<RadioGroup
value={listDensity}
onChange={(value) =>
updateSetting('listDensity', value as 'compact' | 'regular' | 'comfortable')
}
options={[
{ value: 'compact', label: t('list_density.compact') },
{ value: 'regular', label: t('list_density.regular') },
{ value: 'comfortable', label: t('list_density.comfortable') },
]}
/>
</SettingItem>
{/* Animations */}
<SettingItem label={t('animations.label')} description={t('animations.description')}>
<ToggleSwitch
checked={animationsEnabled}
onChange={(checked) => updateSetting('animationsEnabled', checked)}
/>
</SettingItem>
</SettingsSection>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
export function EmailSettings() {
const t = useTranslations('settings.email_behavior');
const {
markAsReadDelay,
deleteAction,
showPreview,
emailsPerPage,
externalContentPolicy,
updateSetting,
} = useSettingsStore();
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Mark as Read */}
<SettingItem label={t('mark_read.label')} description={t('mark_read.description')}>
<Select
value={markAsReadDelay.toString()}
onChange={(value) => updateSetting('markAsReadDelay', parseInt(value))}
options={[
{ value: '0', label: t('mark_read.instant') },
{ value: '3000', label: t('mark_read.delay_3s') },
{ value: '5000', label: t('mark_read.delay_5s') },
{ value: '-1', label: t('mark_read.never') },
]}
/>
</SettingItem>
{/* Delete Action */}
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')}>
<Select
value={deleteAction}
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'permanent')}
options={[
{ value: 'trash', label: t('delete_action.trash') },
{ value: 'permanent', label: t('delete_action.permanent') },
]}
/>
</SettingItem>
{/* Show Preview */}
<SettingItem label={t('show_preview.label')} description={t('show_preview.description')}>
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
</SettingItem>
{/* Emails Per Page */}
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')}>
<Select
value={emailsPerPage.toString()}
onChange={(value) => updateSetting('emailsPerPage', parseInt(value))}
options={[
{ value: '25', label: t('emails_per_page.25') },
{ value: '50', label: t('emails_per_page.50') },
{ value: '100', label: t('emails_per_page.100') },
]}
/>
</SettingItem>
{/* External Content */}
<SettingItem label={t('external_content.label')} description={t('external_content.description')}>
<Select
value={externalContentPolicy}
onChange={(value) =>
updateSetting('externalContentPolicy', value as 'ask' | 'block' | 'allow')
}
options={[
{ value: 'ask', label: t('external_content.ask') },
{ value: 'block', label: t('external_content.block') },
{ value: 'allow', label: t('external_content.allow') },
]}
/>
</SettingItem>
</SettingsSection>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { ReactNode } from 'react';
interface SettingsSectionProps {
title: string;
description?: string;
children: ReactNode;
}
export function SettingsSection({ title, description, children }: SettingsSectionProps) {
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium text-foreground">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
<div className="space-y-4">{children}</div>
</div>
);
}
interface SettingItemProps {
label: string;
description?: string;
children: ReactNode;
}
export function SettingItem({ label, description, children }: SettingItemProps) {
return (
<div className="flex items-start justify-between py-3 border-b border-border last:border-0">
<div className="flex-1 pr-4">
<label className="text-sm font-medium text-foreground">{label}</label>
{description && (
<p className="text-xs text-muted-foreground mt-1">{description}</p>
)}
</div>
<div className="flex-shrink-0">{children}</div>
</div>
);
}
interface ToggleSwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
export function ToggleSwitch({ checked, onChange, disabled }: ToggleSwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors
${checked ? 'bg-primary' : 'bg-muted'}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full bg-background transition-transform
${checked ? 'translate-x-6' : 'translate-x-1'}
`}
/>
</button>
);
}
interface RadioGroupProps {
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}
export function RadioGroup({ value, onChange, options }: RadioGroupProps) {
return (
<div className="flex gap-2">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={`
px-3 py-1.5 text-xs rounded transition-colors
${
value === option.value
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-accent text-foreground'
}
`}
>
{option.label}
</button>
))}
</div>
);
}
interface SelectProps {
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}
export function Select({ value, onChange, options }: SelectProps) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
+205
View File
@@ -199,5 +199,210 @@
"title": "Language",
"english": "English",
"french": "Français"
},
"settings": {
"title": "Settings",
"back_to_mail": "Back to Mail",
"save_success": "Settings saved successfully",
"import_success": "Settings imported successfully",
"import_error": "Failed to import settings",
"reset_confirm": "Are you sure you want to reset all settings to defaults?",
"tabs": {
"appearance": "Appearance",
"language": "Language & Region",
"email": "Email Behavior",
"composer": "Composer",
"privacy": "Privacy & Security",
"account": "Account",
"advanced": "Advanced"
},
"appearance": {
"title": "Appearance",
"description": "Customize the look and feel of your webmail",
"theme": {
"label": "Theme",
"description": "Choose your preferred color scheme",
"light": "Light",
"dark": "Dark",
"system": "System"
},
"font_size": {
"label": "Font Size",
"description": "Adjust text size for better readability",
"small": "Small",
"medium": "Medium",
"large": "Large"
},
"list_density": {
"label": "List Density",
"description": "Control spacing in email lists",
"compact": "Compact",
"regular": "Regular",
"comfortable": "Comfortable"
},
"animations": {
"label": "Enable Animations",
"description": "Show smooth transitions and effects"
}
},
"language_region": {
"title": "Language & Region",
"description": "Configure language and regional preferences",
"language": {
"label": "Language",
"description": "Choose your preferred language",
"english": "English",
"french": "Français"
},
"date_format": {
"label": "Date Format",
"description": "How dates should be displayed",
"regional": "Regional",
"iso": "ISO 8601",
"custom": "Custom"
},
"time_format": {
"label": "Time Format",
"description": "Choose between 12-hour or 24-hour clock",
"12h": "12-hour",
"24h": "24-hour"
},
"first_day": {
"label": "First Day of Week",
"description": "Start week on Sunday or Monday",
"sunday": "Sunday",
"monday": "Monday"
}
},
"email_behavior": {
"title": "Email Behavior",
"description": "Configure how emails are handled",
"mark_read": {
"label": "Mark as Read",
"description": "When to mark emails as read when opened",
"instant": "Instantly",
"delay_3s": "After 3 seconds",
"delay_5s": "After 5 seconds",
"never": "Never"
},
"delete_action": {
"label": "Delete Action",
"description": "What happens when you delete an email",
"trash": "Move to Trash",
"permanent": "Delete Permanently"
},
"show_preview": {
"label": "Show Preview Text",
"description": "Display email preview in the list"
},
"emails_per_page": {
"label": "Emails Per Page",
"description": "Number of emails to load at once",
"25": "25 emails",
"50": "50 emails",
"100": "100 emails"
},
"external_content": {
"label": "External Content",
"description": "How to handle images and external content",
"ask": "Always ask",
"block": "Always block",
"allow": "Always allow"
}
},
"composer": {
"title": "Composer",
"description": "Configure email composition settings",
"autosave": {
"label": "Auto-save Interval",
"description": "How often to save drafts automatically",
"30s": "Every 30 seconds",
"1m": "Every minute",
"2m": "Every 2 minutes",
"5m": "Every 5 minutes"
},
"send_confirmation": {
"label": "Send Confirmation",
"description": "Ask for confirmation before sending emails"
},
"default_reply": {
"label": "Default Reply Mode",
"description": "Default action when clicking reply",
"reply": "Reply",
"reply_all": "Reply All"
}
},
"privacy": {
"title": "Privacy & Security",
"description": "Manage your privacy and security settings",
"external_images": {
"label": "Block External Images",
"description": "Prevent tracking through external images"
},
"session_timeout": {
"label": "Session Timeout",
"description": "Automatically log out after inactivity",
"never": "Never",
"30m": "30 minutes",
"1h": "1 hour",
"4h": "4 hours"
},
"clear_cache": {
"label": "Clear Cache",
"description": "Remove cached data and temporary files",
"button": "Clear Cache",
"confirm": "Are you sure you want to clear the cache?",
"success": "Cache cleared successfully"
}
},
"account": {
"title": "Account",
"description": "View your account information",
"email": {
"label": "Email Address",
"value": "{{email}}"
},
"server": {
"label": "JMAP Server",
"value": "{{server}}"
},
"storage": {
"label": "Storage Usage",
"used": "{{used}} of {{total}} used",
"percentage": "{{percent}}% used"
},
"last_sync": {
"label": "Last Sync",
"value": "{{time}}"
}
},
"advanced": {
"title": "Advanced",
"description": "Advanced options and developer settings",
"debug_mode": {
"label": "Debug Mode",
"description": "Enable detailed logging for troubleshooting"
},
"keyboard_shortcuts": {
"label": "Keyboard Shortcuts",
"description": "View available keyboard shortcuts",
"button": "View Shortcuts"
},
"reset_settings": {
"label": "Reset Settings",
"description": "Restore all settings to default values",
"button": "Reset to Defaults"
},
"export_settings": {
"label": "Export Settings",
"description": "Download your settings as JSON",
"button": "Export"
},
"import_settings": {
"label": "Import Settings",
"description": "Upload settings from JSON file",
"button": "Import"
}
}
}
}
+205
View File
@@ -199,5 +199,210 @@
"title": "Langue",
"english": "English",
"french": "Français"
},
"settings": {
"title": "Paramètres",
"back_to_mail": "Retour aux emails",
"save_success": "Paramètres enregistrés avec succès",
"import_success": "Paramètres importés avec succès",
"import_error": "Échec de l'importation des paramètres",
"reset_confirm": "Êtes-vous sûr de vouloir réinitialiser tous les paramètres ?",
"tabs": {
"appearance": "Apparence",
"language": "Langue et région",
"email": "Comportement email",
"composer": "Compositeur",
"privacy": "Confidentialité et sécurité",
"account": "Compte",
"advanced": "Avancé"
},
"appearance": {
"title": "Apparence",
"description": "Personnalisez l'apparence de votre webmail",
"theme": {
"label": "Thème",
"description": "Choisissez votre schéma de couleurs préféré",
"light": "Clair",
"dark": "Sombre",
"system": "Système"
},
"font_size": {
"label": "Taille de police",
"description": "Ajustez la taille du texte pour une meilleure lisibilité",
"small": "Petite",
"medium": "Moyenne",
"large": "Grande"
},
"list_density": {
"label": "Densité de la liste",
"description": "Contrôlez l'espacement dans les listes d'emails",
"compact": "Compacte",
"regular": "Normale",
"comfortable": "Confortable"
},
"animations": {
"label": "Activer les animations",
"description": "Afficher les transitions et effets fluides"
}
},
"language_region": {
"title": "Langue et région",
"description": "Configurez vos préférences linguistiques et régionales",
"language": {
"label": "Langue",
"description": "Choisissez votre langue préférée",
"english": "English",
"french": "Français"
},
"date_format": {
"label": "Format de date",
"description": "Comment les dates doivent être affichées",
"regional": "Régional",
"iso": "ISO 8601",
"custom": "Personnalisé"
},
"time_format": {
"label": "Format d'heure",
"description": "Choisissez entre 12 heures ou 24 heures",
"12h": "12 heures",
"24h": "24 heures"
},
"first_day": {
"label": "Premier jour de la semaine",
"description": "Commencer la semaine le dimanche ou le lundi",
"sunday": "Dimanche",
"monday": "Lundi"
}
},
"email_behavior": {
"title": "Comportement email",
"description": "Configurez la gestion des emails",
"mark_read": {
"label": "Marquer comme lu",
"description": "Quand marquer les emails comme lus à l'ouverture",
"instant": "Instantanément",
"delay_3s": "Après 3 secondes",
"delay_5s": "Après 5 secondes",
"never": "Jamais"
},
"delete_action": {
"label": "Action de suppression",
"description": "Que se passe-t-il quand vous supprimez un email",
"trash": "Déplacer vers la corbeille",
"permanent": "Supprimer définitivement"
},
"show_preview": {
"label": "Afficher l'aperçu",
"description": "Afficher l'aperçu de l'email dans la liste"
},
"emails_per_page": {
"label": "Emails par page",
"description": "Nombre d'emails à charger à la fois",
"25": "25 emails",
"50": "50 emails",
"100": "100 emails"
},
"external_content": {
"label": "Contenu externe",
"description": "Comment gérer les images et le contenu externe",
"ask": "Toujours demander",
"block": "Toujours bloquer",
"allow": "Toujours autoriser"
}
},
"composer": {
"title": "Compositeur",
"description": "Configurez les paramètres de composition d'email",
"autosave": {
"label": "Intervalle de sauvegarde automatique",
"description": "Fréquence de sauvegarde automatique des brouillons",
"30s": "Toutes les 30 secondes",
"1m": "Toutes les minutes",
"2m": "Toutes les 2 minutes",
"5m": "Toutes les 5 minutes"
},
"send_confirmation": {
"label": "Confirmation d'envoi",
"description": "Demander une confirmation avant d'envoyer les emails"
},
"default_reply": {
"label": "Mode de réponse par défaut",
"description": "Action par défaut lors du clic sur répondre",
"reply": "Répondre",
"reply_all": "Répondre à tous"
}
},
"privacy": {
"title": "Confidentialité et sécurité",
"description": "Gérez vos paramètres de confidentialité et sécurité",
"external_images": {
"label": "Bloquer les images externes",
"description": "Empêcher le suivi par les images externes"
},
"session_timeout": {
"label": "Délai d'expiration de session",
"description": "Déconnexion automatique après inactivité",
"never": "Jamais",
"30m": "30 minutes",
"1h": "1 heure",
"4h": "4 heures"
},
"clear_cache": {
"label": "Vider le cache",
"description": "Supprimer les données en cache et fichiers temporaires",
"button": "Vider le cache",
"confirm": "Êtes-vous sûr de vouloir vider le cache ?",
"success": "Cache vidé avec succès"
}
},
"account": {
"title": "Compte",
"description": "Consultez les informations de votre compte",
"email": {
"label": "Adresse email",
"value": "{{email}}"
},
"server": {
"label": "Serveur JMAP",
"value": "{{server}}"
},
"storage": {
"label": "Utilisation du stockage",
"used": "{{used}} sur {{total}} utilisés",
"percentage": "{{percent}}% utilisé"
},
"last_sync": {
"label": "Dernière synchronisation",
"value": "{{time}}"
}
},
"advanced": {
"title": "Avancé",
"description": "Options avancées et paramètres développeur",
"debug_mode": {
"label": "Mode débogage",
"description": "Activer la journalisation détaillée pour le dépannage"
},
"keyboard_shortcuts": {
"label": "Raccourcis clavier",
"description": "Voir les raccourcis clavier disponibles",
"button": "Voir les raccourcis"
},
"reset_settings": {
"label": "Réinitialiser les paramètres",
"description": "Restaurer tous les paramètres par défaut",
"button": "Réinitialiser"
},
"export_settings": {
"label": "Exporter les paramètres",
"description": "Télécharger vos paramètres au format JSON",
"button": "Exporter"
},
"import_settings": {
"label": "Importer les paramètres",
"description": "Charger les paramètres depuis un fichier JSON",
"button": "Importer"
}
}
}
}
+13
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { JMAPClient } from '@/lib/jmap/client';
import { useEmailStore } from './email-store';
interface AuthState {
isAuthenticated: boolean;
@@ -91,6 +92,18 @@ export const useAuthStore = create<AuthState>()(
// Clear persisted storage
localStorage.removeItem('auth-storage');
// Clear email store state
useEmailStore.setState({
emails: [],
mailboxes: [],
selectedEmail: null,
selectedMailbox: "",
isLoading: false,
error: null,
searchQuery: "",
quota: null,
});
},
checkAuth: async () => {
+23 -3
View File
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { Email, Mailbox } from "@/lib/jmap/types";
import { JMAPClient } from "@/lib/jmap/client";
import { useSettingsStore } from "@/stores/settings-store";
interface EmailStore {
emails: Email[];
@@ -105,7 +106,20 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoading: true, error: null });
try {
const mailboxes = await client.getAllMailboxes();
set({ mailboxes, isLoading: false });
// Auto-select inbox if no mailbox is currently selected
const currentSelectedMailbox = get().selectedMailbox;
if (!currentSelectedMailbox) {
// Find inbox from PRIMARY account (not shared accounts)
const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared);
if (inboxMailbox) {
set({ mailboxes, selectedMailbox: inboxMailbox.id, isLoading: false });
} else {
set({ mailboxes, isLoading: false });
}
} else {
set({ mailboxes, isLoading: false });
}
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to fetch mailboxes",
@@ -127,7 +141,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
const jmapMailboxId = mailbox?.originalId || targetMailboxId;
const result = await client.getEmails(jmapMailboxId, accountId, 50, 0);
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
set({
emails: result.emails,
hasMoreEmails: result.hasMore,
@@ -162,7 +179,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
const result = await client.getEmails(jmapMailboxId, accountId, 50, emails.length);
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length);
set({
emails: [...emails, ...result.emails],
+213
View File
@@ -0,0 +1,213 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type FontSize = 'small' | 'medium' | 'large';
export type ListDensity = 'compact' | 'regular' | 'comfortable';
export type DeleteAction = 'trash' | 'permanent';
export type ReplyMode = 'reply' | 'replyAll';
export type DateFormat = 'regional' | 'iso' | 'custom';
export type TimeFormat = '12h' | '24h';
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
interface SettingsState {
// Appearance
fontSize: FontSize;
listDensity: ListDensity;
animationsEnabled: boolean;
// Language & Region
dateFormat: DateFormat;
timeFormat: TimeFormat;
firstDayOfWeek: FirstDayOfWeek;
// Email Behavior
markAsReadDelay: number; // milliseconds (0 = instant, -1 = never)
deleteAction: DeleteAction;
showPreview: boolean;
emailsPerPage: number;
externalContentPolicy: ExternalContentPolicy;
// Composer
autoSaveDraftInterval: number; // milliseconds
sendConfirmation: boolean;
defaultReplyMode: ReplyMode;
// Privacy & Security
sessionTimeout: number; // minutes (0 = never)
// Advanced
debugMode: boolean;
// Actions
updateSetting: <K extends keyof SettingsState>(
key: K,
value: SettingsState[K]
) => void;
resetToDefaults: () => void;
exportSettings: () => string;
importSettings: (json: string) => boolean;
}
const DEFAULT_SETTINGS = {
// Appearance
fontSize: 'medium' as FontSize,
listDensity: 'regular' as ListDensity,
animationsEnabled: true,
// Language & Region
dateFormat: 'regional' as DateFormat,
timeFormat: '24h' as TimeFormat,
firstDayOfWeek: 1 as FirstDayOfWeek, // Monday
// Email Behavior
markAsReadDelay: 0, // Instant
deleteAction: 'trash' as DeleteAction,
showPreview: true,
emailsPerPage: 50,
externalContentPolicy: 'ask' as ExternalContentPolicy,
// Composer
autoSaveDraftInterval: 60000, // 1 minute
sendConfirmation: false,
defaultReplyMode: 'reply' as ReplyMode,
// Privacy & Security
sessionTimeout: 0, // Never
// Advanced
debugMode: false,
};
export const useSettingsStore = create<SettingsState>()(
persist(
(set, get) => ({
...DEFAULT_SETTINGS,
updateSetting: (key, value) => {
set({ [key]: value });
// Apply font size to document root
if (key === 'fontSize') {
applyFontSize(value as FontSize);
}
// Apply list density to document root
if (key === 'listDensity') {
applyListDensity(value as ListDensity);
}
// Apply animations to document root
if (key === 'animationsEnabled') {
applyAnimations(value as boolean);
}
},
resetToDefaults: () => {
set(DEFAULT_SETTINGS);
applyFontSize(DEFAULT_SETTINGS.fontSize);
applyListDensity(DEFAULT_SETTINGS.listDensity);
applyAnimations(DEFAULT_SETTINGS.animationsEnabled);
},
exportSettings: () => {
const state = get();
const settings = {
fontSize: state.fontSize,
listDensity: state.listDensity,
animationsEnabled: state.animationsEnabled,
dateFormat: state.dateFormat,
timeFormat: state.timeFormat,
firstDayOfWeek: state.firstDayOfWeek,
markAsReadDelay: state.markAsReadDelay,
deleteAction: state.deleteAction,
showPreview: state.showPreview,
emailsPerPage: state.emailsPerPage,
externalContentPolicy: state.externalContentPolicy,
autoSaveDraftInterval: state.autoSaveDraftInterval,
sendConfirmation: state.sendConfirmation,
defaultReplyMode: state.defaultReplyMode,
sessionTimeout: state.sessionTimeout,
debugMode: state.debugMode,
};
return JSON.stringify(settings, null, 2);
},
importSettings: (json: string) => {
try {
const settings = JSON.parse(json);
// Validate settings
if (typeof settings !== 'object' || settings === null) {
return false;
}
// Apply settings
Object.keys(settings).forEach((key) => {
if (key in DEFAULT_SETTINGS) {
set({ [key]: settings[key] });
}
});
// Apply visual settings
applyFontSize(get().fontSize);
applyListDensity(get().listDensity);
applyAnimations(get().animationsEnabled);
return true;
} catch (error) {
console.error('Failed to import settings:', error);
return false;
}
},
}),
{
name: 'settings-storage',
version: 1,
}
)
);
// Helper functions to apply settings to DOM
function applyFontSize(size: FontSize) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const sizeMap = {
small: '14px',
medium: '16px',
large: '18px',
};
root.style.setProperty('--font-size-base', sizeMap[size]);
}
function applyListDensity(density: ListDensity) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const densityMap = {
compact: '32px',
regular: '48px',
comfortable: '64px',
};
root.style.setProperty('--list-item-height', densityMap[density]);
}
function applyAnimations(enabled: boolean) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
if (enabled) {
root.style.removeProperty('--transition-duration');
} else {
root.style.setProperty('--transition-duration', '0s');
}
}
// Initialize settings on load
if (typeof window !== 'undefined') {
const store = useSettingsStore.getState();
applyFontSize(store.fontSize);
applyListDensity(store.listDensity);
applyAnimations(store.animationsEnabled);
}