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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user