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