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
+3 -8
View File
@@ -2,7 +2,7 @@
import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button";
import { ArrowLeft, ChevronLeft, ChevronRight, Plus, Upload } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Upload } from "lucide-react";
import { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
@@ -10,7 +10,6 @@ import type { CalendarViewMode } from "@/stores/calendar-store";
interface CalendarToolbarProps {
selectedDate: Date;
viewMode: CalendarViewMode;
onNavigateBack: () => void;
onPrev: () => void;
onNext: () => void;
onToday: () => void;
@@ -19,12 +18,12 @@ interface CalendarToolbarProps {
onImport?: () => void;
isMobile?: boolean;
firstDayOfWeek?: number;
onNavigateBack?: () => void;
}
export function CalendarToolbar({
selectedDate,
viewMode,
onNavigateBack,
onPrev,
onNext,
onToday,
@@ -60,11 +59,6 @@ export function CalendarToolbar({
return (
<div className="flex items-center gap-2 px-4 py-3 border-b border-border flex-wrap">
<Button variant="ghost" size="sm" onClick={onNavigateBack} className="mr-1">
<ArrowLeft className="w-4 h-4 mr-1" />
{!isMobile && t("back_to_email")}
</Button>
<div className="flex items-center gap-1">
<button onClick={onPrev} className="p-1.5 rounded hover:bg-muted transition-colors" aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
@@ -89,6 +83,7 @@ export function CalendarToolbar({
<button
key={v}
onClick={() => onViewModeChange(v)}
title={t(`views.${v}_hint`)}
className={cn(
"px-3 py-1.5 text-xs font-medium transition-colors",
v === viewMode
+10 -7
View File
@@ -41,17 +41,17 @@ function parseDuration(duration: string): number {
return totalMinutes;
}
function createEventDragPreview(title: string, color: string): HTMLElement {
function createEventDragPreview(title: string, timeRange: string, color: string): HTMLElement {
const el = document.createElement("div");
el.style.cssText = `
position: fixed; top: -9999px; left: 0;
padding: 6px 12px; border-radius: 6px;
background: ${color}40; border-left: 3px solid ${color};
color: ${color}; font-size: 12px; font-weight: 500;
max-width: 200px; white-space: nowrap; overflow: hidden;
max-width: 240px; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; pointer-events: none; z-index: 9999;
`;
el.textContent = title;
el.textContent = `${title} \u2022 ${timeRange}`;
document.body.appendChild(el);
return el;
}
@@ -80,7 +80,7 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
}));
const displayTitle = event.title || t("events.no_title");
e.dataTransfer.setData("text/plain", displayTitle);
const preview = createEventDragPreview(displayTitle, color);
const preview = createEventDragPreview(displayTitle, timeString, color);
e.dataTransfer.setDragImage(preview, 0, 0);
requestAnimationFrame(() => preview.remove());
setIsBeingDragged(true);
@@ -135,13 +135,16 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color }}
>
<div className="font-medium truncate">{event.title || t("events.no_title")}</div>
{durationMinutes > 30 && (
{!event.showWithoutTime && (
<div className="opacity-80 text-[10px]">
{timeString}
</div>
)}
{durationMinutes > 30 && getParticipantCount(event) > 0 && (
<div className="flex items-center gap-0.5 opacity-70 text-[10px]">
{getParticipantCount(event) > 0 && (
<div
className="flex items-center gap-0.5 opacity-70 text-[10px]"
title={t("participants.count", { count: getParticipantCount(event) })}
>
<Users className="w-3 h-3" />
<span>{getParticipantCount(event)}</span>
</div>
+35 -15
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2, Check, HelpCircle, XCircle, Users } from "lucide-react";
import { X, Trash2, Check, Users, CalendarDays } from "lucide-react";
import { format, parseISO, addHours } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration } from "./event-card";
@@ -94,6 +94,12 @@ export function EventModal({
return getParticipantList(event);
}, [event]);
const organizerInfo = useMemo(() => {
if (!event?.participants) return null;
const organizer = existingParticipants.find(p => p.isOrganizer);
return organizer ? { name: organizer.name, email: organizer.email } : null;
}, [event, existingParticipants]);
const getInitialStart = (): Date => {
if (event?.start) return parseISO(event.start);
if (defaultDate) {
@@ -342,6 +348,18 @@ export function EventModal({
</div>
<div className="px-5 py-4 space-y-3">
<div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3">
<CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium text-blue-900 dark:text-blue-200">
{t("participants.invited_by", { name: organizerInfo?.name || organizerInfo?.email || t("participants.organizer") })}
</p>
<p className="text-blue-700 dark:text-blue-400 mt-0.5">
{t("participants.respond_below")}
</p>
</div>
</div>
<div className="text-sm">
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span>
{!event.showWithoutTime && (
@@ -359,10 +377,6 @@ export function EventModal({
<p className="text-sm text-muted-foreground">{locationName}</p>
)}
<div className="text-xs text-muted-foreground">
{t("participants.you_attendee")}
</div>
{participants.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-sm font-medium">
@@ -383,33 +397,39 @@ export function EventModal({
<div className="px-5 py-4 border-t border-border">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">RSVP</span>
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
<div className="flex gap-2">
<Button
size="sm"
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
onClick={() => handleRsvp("accepted")}
className={userCurrentStatus === "accepted" ? "bg-green-600 hover:bg-green-700 text-white ring-2 ring-green-300 dark:ring-green-700" : "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"}
className={userCurrentStatus === "accepted"
? "bg-green-600 hover:bg-green-700 text-white dark:bg-green-500 dark:hover:bg-green-600"
: "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"}
>
<Check className="w-4 h-4 mr-1" />
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
{t("participants.accepted")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
onClick={() => handleRsvp("tentative")}
className={userCurrentStatus === "tentative" ? "bg-amber-600 hover:bg-amber-700 text-white ring-2 ring-amber-300 dark:ring-amber-700" : "text-amber-600 dark:text-amber-400 border-amber-300 dark:border-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950"}
className={userCurrentStatus === "tentative"
? "bg-amber-600 hover:bg-amber-700 text-white dark:bg-amber-500 dark:hover:bg-amber-600"
: "border border-amber-500 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950"}
>
<HelpCircle className="w-4 h-4 mr-1" />
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
{t("participants.tentative")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "declined" ? "default" : "outline"}
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
onClick={() => handleRsvp("declined")}
className={userCurrentStatus === "declined" ? "bg-red-600 hover:bg-red-700 text-white ring-2 ring-red-300 dark:ring-red-700" : "text-red-600 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-50 dark:hover:bg-red-950"}
className={userCurrentStatus === "declined"
? "bg-red-600 hover:bg-red-700 text-white dark:bg-red-500 dark:hover:bg-red-600"
: "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"}
>
<XCircle className="w-4 h-4 mr-1" />
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
{t("participants.declined")}
</Button>
</div>
@@ -500,7 +520,7 @@ export function EventModal({
<label htmlFor="allDay" className="text-sm">{t("form.all_day_event")}</label>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium mb-1 block">{t("form.start_date")}</label>
<input
@@ -560,7 +580,7 @@ export function EventModal({
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<select
+6 -2
View File
@@ -2,7 +2,7 @@
import { useState, useMemo } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
addMonths, subMonths, addYears, subYears, setMonth, setYear,
@@ -113,13 +113,17 @@ export function MiniCalendar({
<button
onClick={handleHeaderClick}
disabled={pickerView === "years"}
title={pickerView !== "years" ? t("mini_calendar_change") : undefined}
className={cn(
"text-sm font-medium px-1 rounded transition-colors",
"text-sm font-medium px-2 py-1 rounded-md transition-colors inline-flex items-center gap-1",
pickerView !== "years" && "hover:bg-muted cursor-pointer",
pickerView === "years" && "cursor-default"
)}
>
{headerLabel}
{pickerView !== "years" && (
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
<button
onClick={handleNext}