feat: add UI/UX polish with navigation rail, confirm dialogs, welcome banner, and form validation

- Add NavigationRail component (desktop vertical icon sidebar + mobile bottom tab bar)
- Add ConfirmDialog with promise-based useConfirmDialog hook for async confirmation flow
- Add WelcomeBanner onboarding component (one-time display, localStorage persistence)
- Polish login form UX (shake on error, TOTP slide animation, password visibility toggle, session expired banner)
- Add inline form validation with shake animation in email composer and contacts
- Add empty state patterns for contacts (no data vs no search results with contextual actions)
- Improve toast notification system with undo action support and typed durations
- Add WCAG AA prefers-reduced-motion media query, safe area insets, sr-only live regions
- Add template settings tab and keyboard shortcut integration
- Update all 8 locale translations
This commit is contained in:
Matthieu MALVACHE
2026-02-17 02:31:50 +01:00
committed by Matthieu MALVACHE
parent 2636a88820
commit a43096485b
44 changed files with 2148 additions and 669 deletions
+46 -32
View File
@@ -23,6 +23,7 @@ import { MiniCalendar } from "@/components/calendar/mini-calendar";
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
import { EventModal } from "@/components/calendar/event-modal";
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { NavigationRail } from "@/components/layout/navigation-rail";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
export default function CalendarPage() {
@@ -308,40 +309,53 @@ export default function CalendarPage() {
};
return (
<div className="flex flex-col h-screen bg-background">
<CalendarToolbar
selectedDate={selectedDate}
viewMode={viewMode}
onNavigateBack={() => router.push("/")}
onPrev={navigatePrev}
onNext={navigateNext}
onToday={goToToday}
onViewModeChange={setViewMode}
onCreateEvent={() => openCreateModal()}
onImport={() => setShowImportModal(true)}
isMobile={isMobile}
/>
<div className="flex h-screen bg-background">
{/* Left Navigation Rail */}
{!isMobile && (
<div className="w-14 border-r border-border bg-secondary flex flex-col items-center py-3 flex-shrink-0">
<NavigationRail collapsed className="py-0" />
</div>
)}
<div className="flex flex-1 overflow-hidden">
{!isMobile && (
<div className="w-60 border-r border-border p-3 overflow-y-auto flex-shrink-0">
<MiniCalendar
selectedDate={selectedDate}
displayMonth={miniMonth}
onSelectDate={handleSelectDate}
onChangeMonth={handleMiniMonthChange}
events={events}
firstDayOfWeek={firstDayOfWeek}
/>
<CalendarSidebarPanel
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
/>
</div>
<div className="flex flex-col flex-1 min-w-0">
<CalendarToolbar
selectedDate={selectedDate}
viewMode={viewMode}
onPrev={navigatePrev}
onNext={navigateNext}
onToday={goToToday}
onViewModeChange={setViewMode}
onCreateEvent={() => openCreateModal()}
onImport={() => setShowImportModal(true)}
isMobile={isMobile}
/>
<div className="flex flex-1 overflow-hidden">
{!isMobile && (
<div className="w-60 border-r border-border p-3 overflow-y-auto flex-shrink-0">
<MiniCalendar
selectedDate={selectedDate}
displayMonth={miniMonth}
onSelectDate={handleSelectDate}
onChangeMonth={handleMiniMonthChange}
events={events}
firstDayOfWeek={firstDayOfWeek}
/>
<CalendarSidebarPanel
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
/>
</div>
)}
{renderView()}
</div>
{/* Mobile Bottom Navigation */}
{isMobile && (
<NavigationRail orientation="horizontal" />
)}
{renderView()}
</div>
{showEventModal && (
+50 -6
View File
@@ -5,6 +5,8 @@ import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { ArrowLeft, Upload, Download, Users, BookUser } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { ContactList } from "@/components/contacts/contact-list";
import { ContactDetail } from "@/components/contacts/contact-detail";
import { ContactForm } from "@/components/contacts/contact-form";
@@ -17,6 +19,8 @@ import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { useIsMobile } from "@/hooks/use-media-query";
import type { ContactCard } from "@/lib/jmap/types";
type View =
@@ -68,6 +72,8 @@ export default function ContactsPage() {
const [view, setView] = useState<View>("list");
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const isMobile = useIsMobile();
useEffect(() => {
if (!isAuthenticated) {
@@ -105,7 +111,14 @@ export default function ContactsPage() {
const handleDelete = async () => {
if (!selectedContact) return;
if (!window.confirm(t("delete_confirm"))) return;
const confirmed = await confirmDialog({
title: t("delete_confirm_title"),
message: t("delete_confirm"),
confirmText: t("form.delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
if (supportsSync && client) {
@@ -178,7 +191,14 @@ export default function ContactsPage() {
const handleDeleteGroup = async () => {
if (!selectedGroup) return;
if (!window.confirm(t("groups.delete_confirm"))) return;
const confirmed = await confirmDialog({
title: t("groups.delete_confirm_title"),
message: t("groups.delete_confirm"),
confirmText: t("form.delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
await deleteGroup(supportsSync && client ? client : null, selectedGroup.id);
@@ -228,7 +248,14 @@ export default function ContactsPage() {
const handleBulkDelete = async () => {
if (selectedContactIds.size === 0) return;
if (!window.confirm(t("bulk.delete_confirm", { count: selectedContactIds.size }))) return;
const confirmed = await confirmDialog({
title: t("bulk.delete_confirm_title"),
message: t("bulk.delete_confirm", { count: selectedContactIds.size }),
confirmText: t("bulk.delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
await bulkDeleteContacts(
@@ -402,7 +429,15 @@ export default function ContactsPage() {
return (
<div className="flex h-screen bg-background">
<div className="w-80 border-r border-border flex flex-col">
{!isMobile && (
<div className="w-14 border-r border-border bg-secondary flex flex-col items-center flex-shrink-0">
<NavigationRail collapsed />
</div>
)}
<div className="flex flex-col flex-1 min-w-0">
<div className="flex flex-1 min-h-0">
<div className="w-80 border-r border-border flex flex-col flex-shrink-0">
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between">
<Button
@@ -482,6 +517,7 @@ export default function ContactsPage() {
onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew}
onImport={() => setView("import")}
supportsSync={supportsSync}
className="flex-1"
selectedContactIds={selectedContactIds}
@@ -504,9 +540,17 @@ export default function ContactsPage() {
)}
</div>
<div className="flex-1">
{renderRightPanel()}
<div className="flex-1 min-w-0">
{renderRightPanel()}
</div>
</div>
{isMobile && (
<NavigationRail orientation="horizontal" />
)}
</div>
<ConfirmDialog {...confirmDialogProps} />
</div>
);
}
+147 -58
View File
@@ -7,7 +7,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth-store";
import { useConfig } from "@/hooks/use-config";
import { Mail, AlertCircle, Loader2, X, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import { Mail, AlertCircle, Loader2, X, ShieldCheck, Info, Eye, EyeOff } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
@@ -15,13 +16,15 @@ export default function LoginPage() {
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { appName, jmapServerUrl: serverUrl, isLoading: configLoading, error: configError } = useConfig();
// All hooks must be called unconditionally at the top
const [formData, setFormData] = useState({
username: "",
password: "",
});
const [showTotpField, setShowTotpField] = useState(false);
const [totpCode, setTotpCode] = useState("");
const [sessionExpired, setSessionExpired] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [shakeError, setShakeError] = useState(false);
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
@@ -30,15 +33,33 @@ export default function LoginPage() {
const suggestionsRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const justSelectedSuggestion = useRef(false);
const totpInputRef = useRef<HTMLInputElement>(null);
const prevError = useRef<string | null>(null);
// Set page title
useEffect(() => {
if (serverUrl) {
document.title = appName;
}
}, [appName, serverUrl]);
// Load saved usernames from localStorage on mount
useEffect(() => {
try {
if (sessionStorage.getItem('session_expired') === 'true') {
setSessionExpired(true);
sessionStorage.removeItem('session_expired');
}
} catch { /* sessionStorage unavailable */ }
}, []);
useEffect(() => {
if (error && error !== prevError.current) {
setShakeError(true);
const timer = setTimeout(() => setShakeError(false), 400);
return () => clearTimeout(timer);
}
prevError.current = error;
}, [error]);
useEffect(() => {
if (!serverUrl) return;
const saved = localStorage.getItem("webmail_usernames");
@@ -62,10 +83,8 @@ export default function LoginPage() {
clearError();
}, [formData, clearError]);
// Filter suggestions based on input
useEffect(() => {
if (!serverUrl) return;
// Skip showing suggestions if we just selected one
if (justSelectedSuggestion.current) {
justSelectedSuggestion.current = false;
return;
@@ -79,14 +98,13 @@ export default function LoginPage() {
setShowSuggestions(filtered.length > 0);
} else if (formData.username === "" && savedUsernames.length > 0) {
setFilteredSuggestions(savedUsernames);
setShowSuggestions(false); // Don't show on empty input
setShowSuggestions(false);
} else {
setShowSuggestions(false);
}
setSelectedSuggestionIndex(-1);
}, [formData.username, savedUsernames, serverUrl]);
// Close suggestions when clicking outside
useEffect(() => {
if (!serverUrl) return;
const handleClickOutside = (event: MouseEvent) => {
@@ -100,7 +118,12 @@ export default function LoginPage() {
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [serverUrl]);
// Show loading state while config is being fetched
useEffect(() => {
if (showTotpField && totpInputRef.current) {
totpInputRef.current.focus();
}
}, [showTotpField]);
if (configLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
@@ -112,7 +135,6 @@ export default function LoginPage() {
);
}
// Show error if config fetch failed
if (configError) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
@@ -129,7 +151,6 @@ export default function LoginPage() {
);
}
// Show error if JMAP server URL is not configured
if (!serverUrl) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
@@ -146,7 +167,6 @@ export default function LoginPage() {
);
}
// Save username on successful login
const saveUsername = (username: string) => {
const saved = localStorage.getItem("webmail_usernames");
let usernames: string[] = [];
@@ -159,7 +179,6 @@ export default function LoginPage() {
}
}
// Add username if not already present, keep max 5 recent usernames
if (!usernames.includes(username)) {
usernames = [username, ...usernames].slice(0, 5);
localStorage.setItem("webmail_usernames", JSON.stringify(usernames));
@@ -167,7 +186,6 @@ export default function LoginPage() {
}
};
// Remove a username from saved list
const removeUsername = (username: string, e: React.MouseEvent) => {
e.stopPropagation();
const updated = savedUsernames.filter(u => u !== username);
@@ -195,7 +213,6 @@ export default function LoginPage() {
justSelectedSuggestion.current = true;
setFormData({ ...formData, username });
setShowSuggestions(false);
// Focus password field
document.getElementById("password")?.focus();
};
@@ -248,6 +265,28 @@ export default function LoginPage() {
</h1>
</div>
{/* Session Expired Banner */}
{sessionExpired && (
<div
className="mb-6 p-4 bg-blue-500/10 border border-blue-500/20 rounded-lg flex items-start gap-3"
role="status"
aria-live="polite"
>
<Info className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-blue-700 dark:text-blue-300 flex-1">
{t("session_expired")}
</p>
<button
type="button"
onClick={() => setSessionExpired(false)}
className="p-0.5 rounded hover:bg-blue-500/10 transition-colors flex-shrink-0"
aria-label={t("dismiss")}
>
<X className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</button>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
@@ -261,8 +300,11 @@ export default function LoginPage() {
)}
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-4">
<form
onSubmit={handleSubmit}
className={cn("space-y-4", shakeError && "animate-shake")}
>
<fieldset disabled={isLoading} className="space-y-4">
<div className="relative">
<Input
ref={inputRef}
@@ -290,9 +332,10 @@ export default function LoginPage() {
{filteredSuggestions.map((username, index) => (
<div
key={username}
className={`px-4 py-2.5 flex items-center justify-between hover:bg-muted cursor-pointer transition-colors ${
index === selectedSuggestionIndex ? "bg-muted" : ""
}`}
className={cn(
"px-4 py-2.5 flex items-center justify-between hover:bg-muted cursor-pointer transition-colors",
index === selectedSuggestionIndex && "bg-muted"
)}
onClick={() => selectSuggestion(username)}
>
<span className="text-sm text-foreground">{username}</span>
@@ -310,45 +353,91 @@ export default function LoginPage() {
)}
</div>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors"
placeholder={t("password_placeholder")}
required
autoComplete="current-password"
/>
{/* TOTP Toggle */}
<button
type="button"
onClick={() => {
setShowTotpField(!showTotpField);
if (showTotpField) setTotpCode("");
}}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ShieldCheck className="w-4 h-4" />
{showTotpField ? t("totp_hide") : t("totp_toggle")}
</button>
{/* TOTP Input */}
{showTotpField && (
<div className="relative">
<Input
id="totp"
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors text-center font-mono text-lg tracking-widest"
placeholder={t("totp_placeholder")}
autoComplete="one-time-code"
id="password"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="h-12 px-4 pr-11 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors"
placeholder={t("password_placeholder")}
required
autoComplete="current-password"
/>
)}
</div>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground hover:text-foreground transition-colors"
aria-label={showPassword ? t("hide_password") : t("show_password")}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="w-4.5 h-4.5" />
) : (
<Eye className="w-4.5 h-4.5" />
)}
</button>
</div>
{/* 2FA Checkbox */}
<div>
<label className="flex items-center gap-2.5 cursor-pointer group select-none">
<span className="relative flex items-center justify-center">
<input
type="checkbox"
checked={showTotpField}
onChange={(e) => {
setShowTotpField(e.target.checked);
if (!e.target.checked) setTotpCode("");
}}
className="peer sr-only"
/>
<span className="flex items-center justify-center w-4.5 h-4.5 rounded border border-border bg-secondary/50 peer-checked:bg-primary peer-checked:border-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background transition-colors">
{showTotpField && (
<svg className="w-3 h-3 text-primary-foreground" viewBox="0 0 12 12" fill="none">
<path d="M2 6L5 9L10 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
</span>
<span className="flex items-center gap-1.5 text-sm text-muted-foreground group-hover:text-foreground transition-colors">
<ShieldCheck className="w-4 h-4" />
{t("totp_checkbox")}
</span>
</label>
{!showTotpField && (
<p className="text-xs text-muted-foreground/80 mt-1.5 ml-7">
{t("totp_hint")}
</p>
)}
</div>
{/* TOTP Input with slide animation */}
<div
className="grid transition-all duration-200 ease-out"
style={{
gridTemplateRows: showTotpField ? '1fr' : '0fr',
opacity: showTotpField ? 1 : 0,
}}
>
<div className="overflow-hidden">
<Input
ref={totpInputRef}
id="totp"
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors text-center font-mono text-lg tracking-widest"
placeholder={t("totp_placeholder")}
autoComplete="one-time-code"
tabIndex={showTotpField ? 0 : -1}
aria-hidden={!showTotpField}
/>
</div>
</div>
</fieldset>
<Button
type="submit"
@@ -368,4 +457,4 @@ export default function LoginPage() {
</div>
</div>
);
}
}
+28 -2
View File
@@ -30,6 +30,8 @@ import {
import { DragDropProvider } from "@/contexts/drag-drop-context";
import { AdvancedSearchPanel } from "@/components/search/advanced-search-panel";
import { isFilterEmpty } from "@/lib/jmap/search-utils";
import { WelcomeBanner } from "@/components/ui/welcome-banner";
import { NavigationRail } from "@/components/layout/navigation-rail";
export default function Home() {
const router = useRouter();
@@ -37,6 +39,7 @@ export default function Home() {
const tCommon = useTranslations('common');
const [showComposer, setShowComposer] = useState(false);
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [composerDraftText, setComposerDraftText] = useState("");
const [initialCheckDone, setInitialCheckDone] = useState(false);
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
// Mobile conversation view state
@@ -395,7 +398,8 @@ export default function Home() {
}
};
const handleReply = () => {
const handleReply = (draftText?: string) => {
setComposerDraftText(draftText || "");
setComposerMode('reply');
setShowComposer(true);
};
@@ -718,6 +722,13 @@ export default function Home() {
return (
<DragDropProvider>
<div className="flex h-screen bg-background overflow-hidden">
{/* Desktop Navigation Rail */}
{!isMobile && !isTablet && (
<div className="w-14 border-r border-border bg-secondary flex flex-col items-center flex-shrink-0">
<NavigationRail collapsed />
</div>
)}
{/* Mobile/Tablet Sidebar Overlay Backdrop */}
{(isMobile || isTablet) && sidebarOpen && (
<div
@@ -760,7 +771,8 @@ export default function Home() {
</div>
{/* Main Content Area */}
<div className="flex flex-1 min-w-0 h-full">
<div className="flex flex-col flex-1 min-w-0 h-full">
<div className="flex flex-1 min-h-0">
{/* Email List - full width on mobile, fixed width on tablet/desktop */}
<div
className={cn(
@@ -796,6 +808,8 @@ export default function Home() {
onClose={toggleAdvancedSearch}
/>
<WelcomeBanner />
<ErrorBoundary fallback={EmailListErrorFallback}>
<EmailList
emails={emails}
@@ -917,6 +931,7 @@ export default function Home() {
setTabletListVisible(true);
selectEmail(null);
}}
onShowShortcuts={() => setShowShortcutsModal(true)}
currentUserEmail={client?.["username"]}
currentUserName={client?.["username"]?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
@@ -926,6 +941,12 @@ export default function Home() {
</>
)}
</div>
</div>
{/* Mobile Bottom Navigation */}
{isMobile && activeView !== "viewer" && (
<NavigationRail orientation="horizontal" />
)}
</div>
{/* Email Composer Modal */}
@@ -952,10 +973,12 @@ export default function Home() {
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
receivedAt: selectedEmail.receivedAt
} : undefined}
initialDraftText={composerDraftText}
onSend={handleEmailSend}
onClose={() => {
setShowComposer(false);
setComposerMode('compose');
setComposerDraftText("");
}}
onDiscardDraft={handleDiscardDraft}
/>
@@ -969,6 +992,9 @@ export default function Home() {
isOpen={showShortcutsModal}
onClose={() => setShowShortcutsModal(false)}
/>
{/* Screen reader live region for dynamic status announcements */}
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
</div>
</DragDropProvider>
);
+21
View File
@@ -296,6 +296,7 @@ body {
animation: slide-in 0.3s ease-out;
}
/* Mobile Responsive Utilities */
/* Safe area insets for notched devices (iPhone X+, etc.) */
@@ -385,3 +386,23 @@ body {
.animate-slide-in-from-left {
animation: slide-in-from-left 0.3s ease-out;
}
/* Reduced motion: respect user OS preference */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Disable backdrop-filter on mobile for performance */
@media (max-width: 767px) {
.mobile-backdrop {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
}