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
+14 -27
View File
@@ -43,17 +43,11 @@ export const useAuthStore = create<AuthState>()(
set({ isLoading: true, error: null });
try {
// Create JMAP client
const client = new JMAPClient(serverUrl, username, effectivePassword);
// Try to connect
await client.connect();
// Fetch identities from the server
const identities = await client.getIdentities();
const primaryIdentity = identities.length > 0 ? identities[0] : null;
// Sync identities to identity store
useIdentityStore.getState().setIdentities(identities);
// Fetch contacts if server supports JMAP Contacts
@@ -66,7 +60,6 @@ export const useAuthStore = create<AuthState>()(
useContactStore.getState().setSupportsSync(false);
}
// Initialize vacation responder if supported
const vacationStore = useVacationStore.getState();
if (client.supportsVacationResponse()) {
vacationStore.setSupported(true);
@@ -75,14 +68,12 @@ export const useAuthStore = create<AuthState>()(
vacationStore.setSupported(false);
}
// Initialize calendar if supported
if (client.supportsCalendars()) {
const calendarStore = useCalendarStore.getState();
calendarStore.setSupported(true);
calendarStore.fetchCalendars(client).catch((err) => console.error('Failed to fetch calendars:', err));
}
// Initialize Sieve filters if supported
if (client.supportsSieve()) {
const filterStore = useFilterStore.getState();
filterStore.setSupported(true);
@@ -106,15 +97,23 @@ export const useAuthStore = create<AuthState>()(
debug.error('Login error:', error);
let errorKey = 'generic';
// Map common errors to translation keys
if (error instanceof Error) {
if (error.message.includes('Invalid username or password') ||
error.message.includes('401') ||
error.message.includes('Unauthorized')) {
errorKey = 'invalid_credentials';
} else if (error.message.includes('network') ||
error.message.includes('Failed to fetch')) {
error.message.includes('Failed to fetch') ||
error.message.includes('NetworkError') ||
error.message.includes('ECONNREFUSED')) {
errorKey = 'connection_failed';
} else if (error.message.includes('500') ||
error.message.includes('502') ||
error.message.includes('503') ||
error.message.includes('504') ||
error.message.includes('Internal Server Error') ||
error.message.includes('Service Unavailable')) {
errorKey = 'server_error';
}
}
@@ -131,7 +130,6 @@ export const useAuthStore = create<AuthState>()(
logout: () => {
const state = get();
// Disconnect the JMAP client if it exists
if (state.client) {
state.client.disconnect();
}
@@ -146,10 +144,8 @@ export const useAuthStore = create<AuthState>()(
error: null,
});
// Clear persisted storage
localStorage.removeItem('auth-storage');
// Clear email store state
useEmailStore.setState({
emails: [],
mailboxes: [],
@@ -161,29 +157,21 @@ export const useAuthStore = create<AuthState>()(
quota: null,
});
// Clear identity store state
useIdentityStore.getState().clearIdentities();
// Clear contact store state
useContactStore.getState().clearContacts();
// Clear vacation store state
useVacationStore.getState().clearState();
// Clear calendar store state
useCalendarStore.getState().clearState();
// Clear filter store state
useFilterStore.getState().clearState();
},
checkAuth: async () => {
const state = get();
// If authenticated but no client (e.g., after page refresh), we can't restore the session
// because we don't store passwords for security reasons
if (state.isAuthenticated && !state.client) {
// Reset auth state - user will need to log in again
try {
sessionStorage.setItem('session_expired', 'true');
} catch { /* sessionStorage unavailable */ }
set({
isAuthenticated: false,
isLoading: false,
@@ -193,7 +181,6 @@ export const useAuthStore = create<AuthState>()(
});
}
// Mark loading as complete
set({ isLoading: false });
},
+24 -16
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { Toast } from "@/components/ui/toast";
import { Toast, ToastAction } from "@/components/ui/toast";
interface ToastStore {
toasts: Toast[];
@@ -16,7 +16,7 @@ export const useToastStore = create<ToastStore>((set) => ({
const newToast: Toast = {
...toast,
id,
duration: toast.duration ?? 5000, // Default 5 seconds
duration: toast.duration ?? 5000,
};
set((state) => ({
@@ -35,18 +35,26 @@ export const useToastStore = create<ToastStore>((set) => ({
},
}));
// Helper functions for common toast types
interface ToastOptions {
message?: string;
action?: ToastAction;
duration?: number;
}
function showToast(type: Toast["type"], title: string, options?: string | ToastOptions, defaultDuration?: number): void {
const opts = typeof options === "string" ? { message: options } : options;
useToastStore.getState().addToast({
type,
title,
message: opts?.message,
action: opts?.action,
duration: opts?.duration ?? defaultDuration,
});
}
export const toast = {
success: (title: string, message?: string) => {
useToastStore.getState().addToast({ type: "success", title, message });
},
error: (title: string, message?: string) => {
useToastStore.getState().addToast({ type: "error", title, message, duration: 10000 });
},
info: (title: string, message?: string) => {
useToastStore.getState().addToast({ type: "info", title, message });
},
warning: (title: string, message?: string) => {
useToastStore.getState().addToast({ type: "warning", title, message });
},
};
success: (title: string, options?: string | ToastOptions) => showToast("success", title, options),
error: (title: string, options?: string | ToastOptions) => showToast("error", title, options, 10000),
info: (title: string, options?: string | ToastOptions) => showToast("info", title, options),
warning: (title: string, options?: string | ToastOptions) => showToast("warning", title, options),
};