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
+10 -1
View File
@@ -32,11 +32,20 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server*
- Clean, minimalist three-pane layout - Clean, minimalist three-pane layout
- Dark and light theme support - Dark and light theme support
- Responsive design for mobile and desktop - Responsive design for mobile and desktop
- Navigation rail (desktop icon sidebar + mobile bottom tab bar)
- Keyboard shortcuts for power users - Keyboard shortcuts for power users
- Drag-and-drop email organization - Drag-and-drop email organization
- Right-click context menus - Right-click context menus
- Smooth animations and transitions - Smooth animations and transitions (respects prefers-reduced-motion)
- Infinite scroll pagination - Infinite scroll pagination
- Welcome banner for first-time users
- Confirmation dialogs with promise-based async flow
- Toast notifications with undo action support
- Inline form validation with shake animation feedback
- Empty state patterns with contextual actions
- Login UX polish (error shake, password visibility toggle, session expired banner)
- Safe area inset support for notched devices
- Screen reader live region announcements
### Real-time Updates ### Real-time Updates
- Push notifications via JMAP EventSource - Push notifications via JMAP EventSource
+14
View File
@@ -69,6 +69,16 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] Virtual scrolling for large email lists - [x] Virtual scrolling for large email lists
- [x] Error boundaries - [x] Error boundaries
- [x] Settings page with preferences - [x] Settings page with preferences
- [x] Navigation rail (desktop vertical icon sidebar + mobile bottom tab bar)
- [x] Welcome banner for first-time users (one-time display, localStorage persistence)
- [x] Confirmation dialog component with promise-based useConfirmDialog hook
- [x] Toast notifications with undo action support and typed durations
- [x] Inline form validation with shake animation (email composer, contact form)
- [x] Login UX polish (error shake, TOTP slide animation, password visibility toggle, session expired banner)
- [x] Empty state patterns for contacts (distinct "no data" vs "no search results" with contextual actions)
- [x] WCAG AA reduced-motion media query (global animation/transition reset)
- [x] Safe area inset utilities for notched devices
- [x] Screen reader live region announcements (sr-only)
### Internationalization ### Internationalization
- [x] English language support - [x] English language support
@@ -93,6 +103,10 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] XSS attack prevention with comprehensive validation - [x] XSS attack prevention with comprehensive validation
- [x] CSP Report-Only headers with per-request nonce - [x] CSP Report-Only headers with per-request nonce
- [x] Security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy) - [x] Security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy)
- [x] Reusable focus trap hook (Tab cycling, Escape handling, focus restore)
- [x] WCAG AA prefers-reduced-motion support (global animation/transition reset)
- [x] Safe area insets for notched mobile devices
- [x] Screen reader sr-only live region for dynamic announcements
### Identity Management ### Identity Management
- [x] Multiple sender identities (name, email, signature) - [x] Multiple sender identities (name, email, signature)
+16 -2
View File
@@ -23,6 +23,7 @@ import { MiniCalendar } from "@/components/calendar/mini-calendar";
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel"; import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
import { EventModal } from "@/components/calendar/event-modal"; import { EventModal } from "@/components/calendar/event-modal";
import { ICalImportModal } from "@/components/calendar/ical-import-modal"; import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { NavigationRail } from "@/components/layout/navigation-rail";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
export default function CalendarPage() { export default function CalendarPage() {
@@ -308,11 +309,18 @@ export default function CalendarPage() {
}; };
return ( return (
<div className="flex flex-col h-screen bg-background"> <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-col flex-1 min-w-0">
<CalendarToolbar <CalendarToolbar
selectedDate={selectedDate} selectedDate={selectedDate}
viewMode={viewMode} viewMode={viewMode}
onNavigateBack={() => router.push("/")}
onPrev={navigatePrev} onPrev={navigatePrev}
onNext={navigateNext} onNext={navigateNext}
onToday={goToToday} onToday={goToToday}
@@ -344,6 +352,12 @@ export default function CalendarPage() {
{renderView()} {renderView()}
</div> </div>
{/* Mobile Bottom Navigation */}
{isMobile && (
<NavigationRail orientation="horizontal" />
)}
</div>
{showEventModal && ( {showEventModal && (
<EventModal <EventModal
event={editEvent} event={editEvent}
+49 -5
View File
@@ -5,6 +5,8 @@ import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { ArrowLeft, Upload, Download, Users, BookUser } from "lucide-react"; import { ArrowLeft, Upload, Download, Users, BookUser } from "lucide-react";
import { Button } from "@/components/ui/button"; 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 { ContactList } from "@/components/contacts/contact-list";
import { ContactDetail } from "@/components/contacts/contact-detail"; import { ContactDetail } from "@/components/contacts/contact-detail";
import { ContactForm } from "@/components/contacts/contact-form"; 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 { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils"; 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"; import type { ContactCard } from "@/lib/jmap/types";
type View = type View =
@@ -68,6 +72,8 @@ export default function ContactsPage() {
const [view, setView] = useState<View>("list"); const [view, setView] = useState<View>("list");
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null); const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false); const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const isMobile = useIsMobile();
useEffect(() => { useEffect(() => {
if (!isAuthenticated) { if (!isAuthenticated) {
@@ -105,7 +111,14 @@ export default function ContactsPage() {
const handleDelete = async () => { const handleDelete = async () => {
if (!selectedContact) return; 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 { try {
if (supportsSync && client) { if (supportsSync && client) {
@@ -178,7 +191,14 @@ export default function ContactsPage() {
const handleDeleteGroup = async () => { const handleDeleteGroup = async () => {
if (!selectedGroup) return; 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 { try {
await deleteGroup(supportsSync && client ? client : null, selectedGroup.id); await deleteGroup(supportsSync && client ? client : null, selectedGroup.id);
@@ -228,7 +248,14 @@ export default function ContactsPage() {
const handleBulkDelete = async () => { const handleBulkDelete = async () => {
if (selectedContactIds.size === 0) return; 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 { try {
await bulkDeleteContacts( await bulkDeleteContacts(
@@ -402,7 +429,15 @@ export default function ContactsPage() {
return ( return (
<div className="flex h-screen bg-background"> <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="p-4 border-b border-border">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button <Button
@@ -482,6 +517,7 @@ export default function ContactsPage() {
onSearchChange={setSearchQuery} onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact} onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew} onCreateNew={handleCreateNew}
onImport={() => setView("import")}
supportsSync={supportsSync} supportsSync={supportsSync}
className="flex-1" className="flex-1"
selectedContactIds={selectedContactIds} selectedContactIds={selectedContactIds}
@@ -504,9 +540,17 @@ export default function ContactsPage() {
)} )}
</div> </div>
<div className="flex-1"> <div className="flex-1 min-w-0">
{renderRightPanel()} {renderRightPanel()}
</div> </div>
</div> </div>
{isMobile && (
<NavigationRail orientation="horizontal" />
)}
</div>
<ConfirmDialog {...confirmDialogProps} />
</div>
); );
} }
+122 -33
View File
@@ -7,7 +7,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useConfig } from "@/hooks/use-config"; 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() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
@@ -15,13 +16,15 @@ export default function LoginPage() {
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { appName, jmapServerUrl: serverUrl, isLoading: configLoading, error: configError } = useConfig(); const { appName, jmapServerUrl: serverUrl, isLoading: configLoading, error: configError } = useConfig();
// All hooks must be called unconditionally at the top
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
username: "", username: "",
password: "", password: "",
}); });
const [showTotpField, setShowTotpField] = useState(false); const [showTotpField, setShowTotpField] = useState(false);
const [totpCode, setTotpCode] = useState(""); 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 [savedUsernames, setSavedUsernames] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false);
@@ -30,15 +33,33 @@ export default function LoginPage() {
const suggestionsRef = useRef<HTMLDivElement>(null); const suggestionsRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const justSelectedSuggestion = useRef(false); const justSelectedSuggestion = useRef(false);
const totpInputRef = useRef<HTMLInputElement>(null);
const prevError = useRef<string | null>(null);
// Set page title
useEffect(() => { useEffect(() => {
if (serverUrl) { if (serverUrl) {
document.title = appName; document.title = appName;
} }
}, [appName, serverUrl]); }, [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(() => { useEffect(() => {
if (!serverUrl) return; if (!serverUrl) return;
const saved = localStorage.getItem("webmail_usernames"); const saved = localStorage.getItem("webmail_usernames");
@@ -62,10 +83,8 @@ export default function LoginPage() {
clearError(); clearError();
}, [formData, clearError]); }, [formData, clearError]);
// Filter suggestions based on input
useEffect(() => { useEffect(() => {
if (!serverUrl) return; if (!serverUrl) return;
// Skip showing suggestions if we just selected one
if (justSelectedSuggestion.current) { if (justSelectedSuggestion.current) {
justSelectedSuggestion.current = false; justSelectedSuggestion.current = false;
return; return;
@@ -79,14 +98,13 @@ export default function LoginPage() {
setShowSuggestions(filtered.length > 0); setShowSuggestions(filtered.length > 0);
} else if (formData.username === "" && savedUsernames.length > 0) { } else if (formData.username === "" && savedUsernames.length > 0) {
setFilteredSuggestions(savedUsernames); setFilteredSuggestions(savedUsernames);
setShowSuggestions(false); // Don't show on empty input setShowSuggestions(false);
} else { } else {
setShowSuggestions(false); setShowSuggestions(false);
} }
setSelectedSuggestionIndex(-1); setSelectedSuggestionIndex(-1);
}, [formData.username, savedUsernames, serverUrl]); }, [formData.username, savedUsernames, serverUrl]);
// Close suggestions when clicking outside
useEffect(() => { useEffect(() => {
if (!serverUrl) return; if (!serverUrl) return;
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@@ -100,7 +118,12 @@ export default function LoginPage() {
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [serverUrl]); }, [serverUrl]);
// Show loading state while config is being fetched useEffect(() => {
if (showTotpField && totpInputRef.current) {
totpInputRef.current.focus();
}
}, [showTotpField]);
if (configLoading) { if (configLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20"> <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) { if (configError) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20"> <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) { if (!serverUrl) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20"> <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 saveUsername = (username: string) => {
const saved = localStorage.getItem("webmail_usernames"); const saved = localStorage.getItem("webmail_usernames");
let usernames: string[] = []; 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)) { if (!usernames.includes(username)) {
usernames = [username, ...usernames].slice(0, 5); usernames = [username, ...usernames].slice(0, 5);
localStorage.setItem("webmail_usernames", JSON.stringify(usernames)); 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) => { const removeUsername = (username: string, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
const updated = savedUsernames.filter(u => u !== username); const updated = savedUsernames.filter(u => u !== username);
@@ -195,7 +213,6 @@ export default function LoginPage() {
justSelectedSuggestion.current = true; justSelectedSuggestion.current = true;
setFormData({ ...formData, username }); setFormData({ ...formData, username });
setShowSuggestions(false); setShowSuggestions(false);
// Focus password field
document.getElementById("password")?.focus(); document.getElementById("password")?.focus();
}; };
@@ -248,6 +265,28 @@ export default function LoginPage() {
</h1> </h1>
</div> </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 Message */}
{error && ( {error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3"> <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 */} {/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-4"> <form
<div className="space-y-4"> onSubmit={handleSubmit}
className={cn("space-y-4", shakeError && "animate-shake")}
>
<fieldset disabled={isLoading} className="space-y-4">
<div className="relative"> <div className="relative">
<Input <Input
ref={inputRef} ref={inputRef}
@@ -290,9 +332,10 @@ export default function LoginPage() {
{filteredSuggestions.map((username, index) => ( {filteredSuggestions.map((username, index) => (
<div <div
key={username} key={username}
className={`px-4 py-2.5 flex items-center justify-between hover:bg-muted cursor-pointer transition-colors ${ className={cn(
index === selectedSuggestionIndex ? "bg-muted" : "" "px-4 py-2.5 flex items-center justify-between hover:bg-muted cursor-pointer transition-colors",
}`} index === selectedSuggestionIndex && "bg-muted"
)}
onClick={() => selectSuggestion(username)} onClick={() => selectSuggestion(username)}
> >
<span className="text-sm text-foreground">{username}</span> <span className="text-sm text-foreground">{username}</span>
@@ -310,33 +353,76 @@ export default function LoginPage() {
)} )}
</div> </div>
<div className="relative">
<Input <Input
id="password" id="password"
type="password" type={showPassword ? "text" : "password"}
value={formData.password} value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })} 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" 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")} placeholder={t("password_placeholder")}
required required
autoComplete="current-password" autoComplete="current-password"
/> />
{/* TOTP Toggle */}
<button <button
type="button" type="button"
onClick={() => { onClick={() => setShowPassword(!showPassword)}
setShowTotpField(!showTotpField); className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground hover:text-foreground transition-colors"
if (showTotpField) setTotpCode(""); aria-label={showPassword ? t("hide_password") : t("show_password")}
}} tabIndex={-1}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
<ShieldCheck className="w-4 h-4" /> {showPassword ? (
{showTotpField ? t("totp_hide") : t("totp_toggle")} <EyeOff className="w-4.5 h-4.5" />
) : (
<Eye className="w-4.5 h-4.5" />
)}
</button> </button>
</div>
{/* TOTP Input */} {/* 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 && ( {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 <Input
ref={totpInputRef}
id="totp" id="totp"
type="text" type="text"
inputMode="numeric" inputMode="numeric"
@@ -346,9 +432,12 @@ export default function LoginPage() {
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" 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")} placeholder={t("totp_placeholder")}
autoComplete="one-time-code" autoComplete="one-time-code"
tabIndex={showTotpField ? 0 : -1}
aria-hidden={!showTotpField}
/> />
)}
</div> </div>
</div>
</fieldset>
<Button <Button
type="submit" type="submit"
+28 -2
View File
@@ -30,6 +30,8 @@ import {
import { DragDropProvider } from "@/contexts/drag-drop-context"; import { DragDropProvider } from "@/contexts/drag-drop-context";
import { AdvancedSearchPanel } from "@/components/search/advanced-search-panel"; import { AdvancedSearchPanel } from "@/components/search/advanced-search-panel";
import { isFilterEmpty } from "@/lib/jmap/search-utils"; 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() { export default function Home() {
const router = useRouter(); const router = useRouter();
@@ -37,6 +39,7 @@ export default function Home() {
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const [showComposer, setShowComposer] = useState(false); const [showComposer, setShowComposer] = useState(false);
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose'); const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [composerDraftText, setComposerDraftText] = useState("");
const [initialCheckDone, setInitialCheckDone] = useState(false); const [initialCheckDone, setInitialCheckDone] = useState(false);
const [showShortcutsModal, setShowShortcutsModal] = useState(false); const [showShortcutsModal, setShowShortcutsModal] = useState(false);
// Mobile conversation view state // Mobile conversation view state
@@ -395,7 +398,8 @@ export default function Home() {
} }
}; };
const handleReply = () => { const handleReply = (draftText?: string) => {
setComposerDraftText(draftText || "");
setComposerMode('reply'); setComposerMode('reply');
setShowComposer(true); setShowComposer(true);
}; };
@@ -718,6 +722,13 @@ export default function Home() {
return ( return (
<DragDropProvider> <DragDropProvider>
<div className="flex h-screen bg-background overflow-hidden"> <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 */} {/* Mobile/Tablet Sidebar Overlay Backdrop */}
{(isMobile || isTablet) && sidebarOpen && ( {(isMobile || isTablet) && sidebarOpen && (
<div <div
@@ -760,7 +771,8 @@ export default function Home() {
</div> </div>
{/* Main Content Area */} {/* 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 */} {/* Email List - full width on mobile, fixed width on tablet/desktop */}
<div <div
className={cn( className={cn(
@@ -796,6 +808,8 @@ export default function Home() {
onClose={toggleAdvancedSearch} onClose={toggleAdvancedSearch}
/> />
<WelcomeBanner />
<ErrorBoundary fallback={EmailListErrorFallback}> <ErrorBoundary fallback={EmailListErrorFallback}>
<EmailList <EmailList
emails={emails} emails={emails}
@@ -917,6 +931,7 @@ export default function Home() {
setTabletListVisible(true); setTabletListVisible(true);
selectEmail(null); selectEmail(null);
}} }}
onShowShortcuts={() => setShowShortcutsModal(true)}
currentUserEmail={client?.["username"]} currentUserEmail={client?.["username"]}
currentUserName={client?.["username"]?.split("@")[0]} currentUserName={client?.["username"]?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role} currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
@@ -928,6 +943,12 @@ export default function Home() {
</div> </div>
</div> </div>
{/* Mobile Bottom Navigation */}
{isMobile && activeView !== "viewer" && (
<NavigationRail orientation="horizontal" />
)}
</div>
{/* Email Composer Modal */} {/* Email Composer Modal */}
{showComposer && ( {showComposer && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 lg:p-0"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 lg:p-0">
@@ -952,10 +973,12 @@ export default function Home() {
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '', body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
receivedAt: selectedEmail.receivedAt receivedAt: selectedEmail.receivedAt
} : undefined} } : undefined}
initialDraftText={composerDraftText}
onSend={handleEmailSend} onSend={handleEmailSend}
onClose={() => { onClose={() => {
setShowComposer(false); setShowComposer(false);
setComposerMode('compose'); setComposerMode('compose');
setComposerDraftText("");
}} }}
onDiscardDraft={handleDiscardDraft} onDiscardDraft={handleDiscardDraft}
/> />
@@ -969,6 +992,9 @@ export default function Home() {
isOpen={showShortcutsModal} isOpen={showShortcutsModal}
onClose={() => setShowShortcutsModal(false)} 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> </div>
</DragDropProvider> </DragDropProvider>
); );
+21
View File
@@ -296,6 +296,7 @@ body {
animation: slide-in 0.3s ease-out; animation: slide-in 0.3s ease-out;
} }
/* Mobile Responsive Utilities */ /* Mobile Responsive Utilities */
/* Safe area insets for notched devices (iPhone X+, etc.) */ /* Safe area insets for notched devices (iPhone X+, etc.) */
@@ -385,3 +386,23 @@ body {
.animate-slide-in-from-left { .animate-slide-in-from-left {
animation: slide-in-from-left 0.3s ease-out; 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;
}
}
+3 -8
View File
@@ -2,7 +2,7 @@
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button"; 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 { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store"; import type { CalendarViewMode } from "@/stores/calendar-store";
@@ -10,7 +10,6 @@ import type { CalendarViewMode } from "@/stores/calendar-store";
interface CalendarToolbarProps { interface CalendarToolbarProps {
selectedDate: Date; selectedDate: Date;
viewMode: CalendarViewMode; viewMode: CalendarViewMode;
onNavigateBack: () => void;
onPrev: () => void; onPrev: () => void;
onNext: () => void; onNext: () => void;
onToday: () => void; onToday: () => void;
@@ -19,12 +18,12 @@ interface CalendarToolbarProps {
onImport?: () => void; onImport?: () => void;
isMobile?: boolean; isMobile?: boolean;
firstDayOfWeek?: number; firstDayOfWeek?: number;
onNavigateBack?: () => void;
} }
export function CalendarToolbar({ export function CalendarToolbar({
selectedDate, selectedDate,
viewMode, viewMode,
onNavigateBack,
onPrev, onPrev,
onNext, onNext,
onToday, onToday,
@@ -60,11 +59,6 @@ export function CalendarToolbar({
return ( return (
<div className="flex items-center gap-2 px-4 py-3 border-b border-border flex-wrap"> <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"> <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")}> <button onClick={onPrev} className="p-1.5 rounded hover:bg-muted transition-colors" aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
@@ -89,6 +83,7 @@ export function CalendarToolbar({
<button <button
key={v} key={v}
onClick={() => onViewModeChange(v)} onClick={() => onViewModeChange(v)}
title={t(`views.${v}_hint`)}
className={cn( className={cn(
"px-3 py-1.5 text-xs font-medium transition-colors", "px-3 py-1.5 text-xs font-medium transition-colors",
v === viewMode v === viewMode
+10 -7
View File
@@ -41,17 +41,17 @@ function parseDuration(duration: string): number {
return totalMinutes; return totalMinutes;
} }
function createEventDragPreview(title: string, color: string): HTMLElement { function createEventDragPreview(title: string, timeRange: string, color: string): HTMLElement {
const el = document.createElement("div"); const el = document.createElement("div");
el.style.cssText = ` el.style.cssText = `
position: fixed; top: -9999px; left: 0; position: fixed; top: -9999px; left: 0;
padding: 6px 12px; border-radius: 6px; padding: 6px 12px; border-radius: 6px;
background: ${color}40; border-left: 3px solid ${color}; background: ${color}40; border-left: 3px solid ${color};
color: ${color}; font-size: 12px; font-weight: 500; 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; text-overflow: ellipsis; pointer-events: none; z-index: 9999;
`; `;
el.textContent = title; el.textContent = `${title} \u2022 ${timeRange}`;
document.body.appendChild(el); document.body.appendChild(el);
return el; return el;
} }
@@ -80,7 +80,7 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
})); }));
const displayTitle = event.title || t("events.no_title"); const displayTitle = event.title || t("events.no_title");
e.dataTransfer.setData("text/plain", displayTitle); e.dataTransfer.setData("text/plain", displayTitle);
const preview = createEventDragPreview(displayTitle, color); const preview = createEventDragPreview(displayTitle, timeString, color);
e.dataTransfer.setDragImage(preview, 0, 0); e.dataTransfer.setDragImage(preview, 0, 0);
requestAnimationFrame(() => preview.remove()); requestAnimationFrame(() => preview.remove());
setIsBeingDragged(true); setIsBeingDragged(true);
@@ -135,13 +135,16 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color }} style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color }}
> >
<div className="font-medium truncate">{event.title || t("events.no_title")}</div> <div className="font-medium truncate">{event.title || t("events.no_title")}</div>
{durationMinutes > 30 && ( {!event.showWithoutTime && (
<div className="opacity-80 text-[10px]"> <div className="opacity-80 text-[10px]">
{timeString} {timeString}
</div> </div>
)} )}
{durationMinutes > 30 && getParticipantCount(event) > 0 && ( {getParticipantCount(event) > 0 && (
<div className="flex items-center gap-0.5 opacity-70 text-[10px]"> <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" /> <Users className="w-3 h-3" />
<span>{getParticipantCount(event)}</span> <span>{getParticipantCount(event)}</span>
</div> </div>
+35 -15
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 { format, parseISO, addHours } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration } from "./event-card"; import { parseDuration } from "./event-card";
@@ -94,6 +94,12 @@ export function EventModal({
return getParticipantList(event); return getParticipantList(event);
}, [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 => { const getInitialStart = (): Date => {
if (event?.start) return parseISO(event.start); if (event?.start) return parseISO(event.start);
if (defaultDate) { if (defaultDate) {
@@ -342,6 +348,18 @@ export function EventModal({
</div> </div>
<div className="px-5 py-4 space-y-3"> <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"> <div className="text-sm">
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span> <span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span>
{!event.showWithoutTime && ( {!event.showWithoutTime && (
@@ -359,10 +377,6 @@ export function EventModal({
<p className="text-sm text-muted-foreground">{locationName}</p> <p className="text-sm text-muted-foreground">{locationName}</p>
)} )}
<div className="text-xs text-muted-foreground">
{t("participants.you_attendee")}
</div>
{participants.length > 0 && ( {participants.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-1.5 text-sm font-medium"> <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="px-5 py-4 border-t border-border">
<div className="flex items-center justify-between"> <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"> <div className="flex gap-2">
<Button <Button
size="sm" size="sm"
variant={userCurrentStatus === "accepted" ? "default" : "outline"} variant={userCurrentStatus === "accepted" ? "default" : "outline"}
onClick={() => handleRsvp("accepted")} 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")} {t("participants.accepted")}
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant={userCurrentStatus === "tentative" ? "default" : "outline"} variant={userCurrentStatus === "tentative" ? "default" : "outline"}
onClick={() => handleRsvp("tentative")} 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")} {t("participants.tentative")}
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant={userCurrentStatus === "declined" ? "default" : "outline"} variant={userCurrentStatus === "declined" ? "default" : "ghost"}
onClick={() => handleRsvp("declined")} 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")} {t("participants.declined")}
</Button> </Button>
</div> </div>
@@ -500,7 +520,7 @@ export function EventModal({
<label htmlFor="allDay" className="text-sm">{t("form.all_day_event")}</label> <label htmlFor="allDay" className="text-sm">{t("form.all_day_event")}</label>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div> <div>
<label className="text-sm font-medium mb-1 block">{t("form.start_date")}</label> <label className="text-sm font-medium mb-1 block">{t("form.start_date")}</label>
<input <input
@@ -560,7 +580,7 @@ export function EventModal({
</div> </div>
)} )}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div> <div>
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label> <label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<select <select
+6 -2
View File
@@ -2,7 +2,7 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
import { import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
addMonths, subMonths, addYears, subYears, setMonth, setYear, addMonths, subMonths, addYears, subYears, setMonth, setYear,
@@ -113,13 +113,17 @@ export function MiniCalendar({
<button <button
onClick={handleHeaderClick} onClick={handleHeaderClick}
disabled={pickerView === "years"} disabled={pickerView === "years"}
title={pickerView !== "years" ? t("mini_calendar_change") : undefined}
className={cn( 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" && "hover:bg-muted cursor-pointer",
pickerView === "years" && "cursor-default" pickerView === "years" && "cursor-default"
)} )}
> >
{headerLabel} {headerLabel}
{pickerView !== "years" && (
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button> </button>
<button <button
onClick={handleNext} onClick={handleNext}
@@ -1,8 +1,16 @@
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContactDetail } from '../contact-detail'; import { ContactDetail } from '../contact-detail';
import type { ContactCard } from '@/lib/jmap/types'; import type { ContactCard } from '@/lib/jmap/types';
beforeEach(() => {
Object.assign(navigator, {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
});
});
const contact: ContactCard = { const contact: ContactCard = {
id: '1', id: '1',
addressBookIds: {}, addressBookIds: {},
@@ -52,10 +60,10 @@ describe('ContactDetail', () => {
it('calls onDelete when delete button is clicked', () => { it('calls onDelete when delete button is clicked', () => {
const onDelete = vi.fn(); const onDelete = vi.fn();
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />); render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
const trashButtons = screen.getAllByRole('button').filter( const deleteButton = screen.getAllByRole('button').find(
btn => btn.querySelector('svg') && btn.textContent?.trim() === '' btn => btn.className.includes('text-red')
); );
fireEvent.click(trashButtons[trashButtons.length - 1]); fireEvent.click(deleteButton!);
expect(onDelete).toHaveBeenCalledOnce(); expect(onDelete).toHaveBeenCalledOnce();
}); });
}); });
@@ -62,7 +62,7 @@ describe('ContactList', () => {
it('shows empty state when no contacts match', () => { it('shows empty state when no contacts match', () => {
render(<ContactList {...defaultProps} contacts={[]} />); render(<ContactList {...defaultProps} contacts={[]} />);
expect(screen.getByText('empty_state')).toBeInTheDocument(); expect(screen.getByText('empty_state_title')).toBeInTheDocument();
}); });
it('shows search empty state when search has no results', () => { it('shows search empty state when search has no results', () => {
+44 -3
View File
@@ -1,12 +1,13 @@
"use client"; "use client";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser } from "lucide-react"; import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send } from "lucide-react";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types"; import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
interface ContactDetailProps { interface ContactDetailProps {
contact: ContactCard | null; contact: ContactCard | null;
@@ -64,13 +65,38 @@ export function ContactDetail({ contact, onEdit, onDelete, className }: ContactD
{emails.length > 0 && ( {emails.length > 0 && (
<Section icon={Mail} title={t("detail.emails")}> <Section icon={Mail} title={t("detail.emails")}>
{emails.map((e, i) => ( {emails.map((e, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2 group">
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline"> <a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline">
{e.address} {e.address}
</a> </a>
{e.contexts && ( {e.contexts && (
<ContextBadge contexts={e.contexts} /> <ContextBadge contexts={e.contexts} />
)} )}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<a
href={`mailto:${e.address}`}
className="p-1 rounded hover:bg-muted transition-colors"
title={t("detail.compose_email")}
aria-label={t("detail.compose_email")}
>
<Send className="w-3.5 h-3.5 text-muted-foreground" />
</a>
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(e.address);
toast.success(t("detail.copied"));
} catch {
toast.error(t("detail.copy_failed"));
}
}}
className="p-1 rounded hover:bg-muted transition-colors"
title={t("detail.copy_email")}
aria-label={t("detail.copy_email")}
>
<Copy className="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div>
</div> </div>
))} ))}
</Section> </Section>
@@ -79,13 +105,28 @@ export function ContactDetail({ contact, onEdit, onDelete, className }: ContactD
{phones.length > 0 && ( {phones.length > 0 && (
<Section icon={Phone} title={t("detail.phones")}> <Section icon={Phone} title={t("detail.phones")}>
{phones.map((p, i) => ( {phones.map((p, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2 group">
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline"> <a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
{p.number} {p.number}
</a> </a>
{p.contexts && ( {p.contexts && (
<ContextBadge contexts={p.contexts} /> <ContextBadge contexts={p.contexts} />
)} )}
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(p.number);
toast.success(t("detail.copied"));
} catch {
toast.error(t("detail.copy_failed"));
}
}}
className="p-1 rounded hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
title={t("detail.copy_phone")}
aria-label={t("detail.copy_phone")}
>
<Copy className="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div> </div>
))} ))}
</Section> </Section>
+43 -5
View File
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { X, Plus } from "lucide-react"; import { X, Plus } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types"; import type { ContactCard } from "@/lib/jmap/types";
interface EmailEntry { interface EmailEntry {
@@ -63,6 +64,24 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
const validateEmail = (address: string): boolean => {
if (!address.trim()) return true;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address.trim());
};
const handleEmailBlur = (index: number, address: string) => {
if (address.trim() && !validateEmail(address)) {
setEmailErrors(prev => ({ ...prev, [index]: t("email_error_inline") }));
} else {
setEmailErrors(prev => {
const next = { ...prev };
delete next[index];
return next;
});
}
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -145,9 +164,11 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
</div> </div>
)} )}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div> <div>
<label className="text-sm text-muted-foreground mb-1 block">{t("given_name")}</label> <label className="text-sm text-muted-foreground mb-1 block">
{t("given_name")} <span className="text-red-500">*</span>
</label>
<Input <Input
value={givenName} value={givenName}
onChange={(e) => setGivenName(e.target.value)} onChange={(e) => setGivenName(e.target.value)}
@@ -156,7 +177,9 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
/> />
</div> </div>
<div> <div>
<label className="text-sm text-muted-foreground mb-1 block">{t("surname")}</label> <label className="text-sm text-muted-foreground mb-1 block">
{t("surname")} <span className="text-red-500">*</span>
</label>
<Input <Input
value={surname} value={surname}
onChange={(e) => setSurname(e.target.value)} onChange={(e) => setSurname(e.target.value)}
@@ -169,17 +192,27 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
<label className="text-sm text-muted-foreground mb-1 block">{t("email")}</label> <label className="text-sm text-muted-foreground mb-1 block">{t("email")}</label>
<div className="space-y-2"> <div className="space-y-2">
{emails.map((entry, i) => ( {emails.map((entry, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i}>
<div className="flex items-center gap-2">
<Input <Input
type="email" type="email"
inputMode="email"
value={entry.address} value={entry.address}
onChange={(e) => { onChange={(e) => {
const next = [...emails]; const next = [...emails];
next[i] = { ...next[i], address: e.target.value }; next[i] = { ...next[i], address: e.target.value };
setEmails(next); setEmails(next);
if (emailErrors[i]) {
setEmailErrors(prev => {
const n = { ...prev };
delete n[i];
return n;
});
}
}} }}
onBlur={() => handleEmailBlur(i, entry.address)}
placeholder={t("email_placeholder")} placeholder={t("email_placeholder")}
className="flex-1" className={cn("flex-1", emailErrors[i] && "border-red-500 focus:ring-red-500")}
/> />
<select <select
value={entry.context} value={entry.context}
@@ -206,6 +239,10 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
</Button> </Button>
)} )}
</div> </div>
{emailErrors[i] && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">{emailErrors[i]}</p>
)}
</div>
))} ))}
<Button <Button
type="button" type="button"
@@ -226,6 +263,7 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
<Input <Input
type="tel" type="tel"
inputMode="tel"
value={entry.number} value={entry.number}
onChange={(e) => { onChange={(e) => {
const next = [...phones]; const next = [...phones];
+37 -6
View File
@@ -2,7 +2,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Search, Plus, BookUser, Info, Check, Trash2, Users, Download, X } from "lucide-react"; import { Search, Plus, BookUser, Info, Check, Trash2, Users, Download, X, UserPlus, Upload } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item"; import { ContactListItem } from "./contact-list-item";
@@ -17,6 +17,7 @@ interface ContactListProps {
onSearchChange: (query: string) => void; onSearchChange: (query: string) => void;
onSelectContact: (id: string) => void; onSelectContact: (id: string) => void;
onCreateNew: () => void; onCreateNew: () => void;
onImport?: () => void;
supportsSync: boolean; supportsSync: boolean;
className?: string; className?: string;
selectedContactIds: Set<string>; selectedContactIds: Set<string>;
@@ -35,6 +36,7 @@ export function ContactList({
onSearchChange, onSearchChange,
onSelectContact, onSelectContact,
onCreateNew, onCreateNew,
onImport,
supportsSync, supportsSync,
className, className,
selectedContactIds, selectedContactIds,
@@ -158,11 +160,40 @@ export function ContactList({
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{sorted.length === 0 ? ( {sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground px-4"> <div className="flex flex-col items-center justify-center h-full px-6 text-center">
<BookUser className="w-12 h-12 mb-3 opacity-30" /> {searchQuery ? (
<p className="text-sm"> <>
{searchQuery ? t("empty_search") : t("empty_state")} <Search className="w-12 h-12 mb-3 text-muted-foreground/30" />
</p> <p className="text-sm font-medium text-foreground">{t("empty_search")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("empty_search_hint")}</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => onSearchChange("")}
>
{t("clear_search")}
</Button>
</>
) : (
<>
<BookUser className="w-12 h-12 mb-3 text-muted-foreground/30" />
<p className="text-sm font-medium text-foreground">{t("empty_state_title")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("empty_state_subtitle")}</p>
<div className="flex gap-2 mt-4">
<Button size="sm" onClick={onCreateNew}>
<UserPlus className="w-4 h-4 mr-1.5" />
{t("create_new")}
</Button>
{onImport && (
<Button variant="outline" size="sm" onClick={onImport}>
<Upload className="w-4 h-4 mr-1.5" />
{t("import_vcard")}
</Button>
)}
</div>
</>
)}
</div> </div>
) : ( ) : (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
+154 -66
View File
@@ -1,12 +1,16 @@
"use client"; "use client";
import { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus } from "lucide-react"; import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useContactStore } from "@/stores/contact-store"; import { useContactStore } from "@/stores/contact-store";
import { useTemplateStore } from "@/stores/template-store"; import { useTemplateStore } from "@/stores/template-store";
@@ -27,10 +31,11 @@ interface EmailComposerProps {
draftId?: string; draftId?: string;
fromEmail?: string; fromEmail?: string;
identityId?: string; identityId?: string;
}) => void; }) => void | Promise<void>;
onClose?: () => void; onClose?: () => void;
onDiscardDraft?: (draftId: string) => void; onDiscardDraft?: (draftId: string) => void;
className?: string; className?: string;
initialDraftText?: string;
mode?: 'compose' | 'reply' | 'replyAll' | 'forward'; mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: { replyTo?: {
from?: { email?: string; name?: string }[]; from?: { email?: string; name?: string }[];
@@ -47,6 +52,7 @@ export function EmailComposer({
onClose, onClose,
onDiscardDraft, onDiscardDraft,
className, className,
initialDraftText,
mode = 'compose', mode = 'compose',
replyTo replyTo
}: EmailComposerProps) { }: EmailComposerProps) {
@@ -84,18 +90,19 @@ export function EmailComposer({
}; };
const getInitialBody = () => { const getInitialBody = () => {
if (!replyTo?.body) return ""; const prefix = initialDraftText || "";
if (!replyTo?.body) return prefix;
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : ""; const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : "";
const from = replyTo.from?.[0]; const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
if (mode === 'forward') { if (mode === 'forward') {
return `\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`; return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`;
} else if (mode === 'reply' || mode === 'replyAll') { } else if (mode === 'reply' || mode === 'replyAll') {
return `\n\nOn ${date}, ${fromStr} wrote:\n> ${replyTo.body.split('\n').join('\n> ')}`; return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n> ${replyTo.body.split('\n').join('\n> ')}`;
} }
return ""; return prefix;
}; };
const [to, setTo] = useState(getInitialTo()); const [to, setTo] = useState(getInitialTo());
@@ -109,12 +116,15 @@ export function EmailComposer({
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null); const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>(""); const lastSavedDataRef = useRef<string>("");
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean }>>([]); const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
const [shakeField, setShakeField] = useState<string | null>(null);
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null); const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
const [subAddressTag, setSubAddressTag] = useState<string>(''); const [subAddressTag, setSubAddressTag] = useState<string>('');
const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog();
const saveTemplateModalRef = useFocusTrap({ const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate, isActive: showSaveAsTemplate,
@@ -132,6 +142,9 @@ export function EmailComposer({
const toInputRef = useRef<HTMLInputElement>(null); const toInputRef = useRef<HTMLInputElement>(null);
const ccInputRef = useRef<HTMLInputElement>(null); const ccInputRef = useRef<HTMLInputElement>(null);
const bccInputRef = useRef<HTMLInputElement>(null); const bccInputRef = useRef<HTMLInputElement>(null);
const toDropdownRef = useRef<HTMLDivElement>(null);
const ccDropdownRef = useRef<HTMLDivElement>(null);
const bccDropdownRef = useRef<HTMLDivElement>(null);
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => { const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
if (autocompleteTimeoutRef.current) { if (autocompleteTimeoutRef.current) {
@@ -170,6 +183,18 @@ export function EmailComposer({
ref.current?.focus(); ref.current?.focus();
}; };
const handleAutoBlur = useCallback((e: React.FocusEvent, field: 'to' | 'cc' | 'bcc') => {
const dropdownRef = field === 'to' ? toDropdownRef : field === 'cc' ? ccDropdownRef : bccDropdownRef;
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) {
return;
}
if (activeAutoField === field) {
setActiveAutoField(null);
setAutoSelectedIndex(-1);
}
}, [activeAutoField]);
const handleAutoKeyDown = (e: React.KeyboardEvent, field: 'to' | 'cc' | 'bcc') => { const handleAutoKeyDown = (e: React.KeyboardEvent, field: 'to' | 'cc' | 'bcc') => {
if (!activeAutoField || autocompleteResults.length === 0) return; if (!activeAutoField || autocompleteResults.length === 0) return;
@@ -235,52 +260,57 @@ export function EmailComposer({
return () => window.removeEventListener('keydown', handleTemplateKey); return () => window.removeEventListener('keydown', handleTemplateKey);
}, []); }, []);
// Handle file selection
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!client || !event.target.files) return; if (!client || !event.target.files) return;
const files = Array.from(event.target.files); const files = Array.from(event.target.files);
// Add files to attachments list with uploading state // AbortController tracks cancellation state but uploadBlob doesn't accept a signal,
const newAttachments = files.map(file => ({ file, uploading: true })); // so abort only prevents post-upload state updates (cosmetic cancellation)
const newAttachments = files.map(file => {
const controller = new AbortController();
return { file, uploading: true, abortController: controller };
});
setAttachments(prev => [...prev, ...newAttachments]); setAttachments(prev => [...prev, ...newAttachments]);
// Upload each file
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i]; const file = files[i];
const controller = newAttachments[i].abortController;
try { try {
if (controller?.signal.aborted) continue;
const { blobId } = await client.uploadBlob(file); const { blobId } = await client.uploadBlob(file);
// Update attachment with blobId if (controller?.signal.aborted) continue;
setAttachments(prev => setAttachments(prev =>
prev.map(att => prev.map(att =>
att.file === file att.file === file
? { ...att, blobId, uploading: false } ? { ...att, blobId, uploading: false, abortController: undefined }
: att : att
) )
); );
} catch (error) { } catch (error) {
console.error(`Failed to upload ${file.name}:`, error); if (controller?.signal.aborted) continue;
debug.error(`Failed to upload ${file.name}:`, error);
toast.error(t('upload_failed', { filename: file.name }));
// Mark attachment as failed
setAttachments(prev => setAttachments(prev =>
prev.map(att => prev.map(att =>
att.file === file att.file === file
? { ...att, uploading: false, error: true } ? { ...att, uploading: false, error: true, abortController: undefined }
: att : att
) )
); );
} }
} }
// Clear the input
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
}; };
// Remove attachment
const removeAttachment = (index: number) => { const removeAttachment = (index: number) => {
const att = attachments[index];
att?.abortController?.abort();
setAttachments(prev => prev.filter((_, i) => i !== index)); setAttachments(prev => prev.filter((_, i) => i !== index));
}; };
@@ -292,7 +322,6 @@ export function EmailComposer({
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean); const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
// Only save if there's some content
if (!toAddresses.length && !subject && !body) { if (!toAddresses.length && !subject && !body) {
return null; return null;
} }
@@ -392,39 +421,62 @@ export function EmailComposer({
}; };
}, []); }, []);
const handleSend = async () => {
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean); const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const hasContent = body || attachments.some(att => att.blobId && !att.uploading);
const canSend = toAddresses.length > 0 && !!subject && hasContent;
const getSendTooltip = (): string | undefined => {
if (canSend) return undefined;
if (toAddresses.length === 0) return t('validation.recipient_required');
if (!subject) return t('validation.subject_required');
if (!hasContent) return t('validation.body_required');
return undefined;
};
const handleSend = async () => {
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean); const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
// Allow sending if we have recipient, subject, and either body text or attachments if (!canSend) {
const hasContent = body || attachments.some(att => att.blobId && !att.uploading); const errors: { to?: boolean; subject?: boolean; body?: boolean } = {};
if (toAddresses.length === 0) errors.to = true;
if (!subject) errors.subject = true;
if (!hasContent) errors.body = true;
setValidationErrors(errors);
if (errors.to) {
setShakeField('to');
setTimeout(() => setShakeField(null), 400);
toInputRef.current?.focus();
}
return;
}
if (toAddresses.length > 0 && subject && hasContent) {
// Wait for any pending auto-save to complete and get the latest draft ID
let finalDraftId = draftId; let finalDraftId = draftId;
if (saveTimeoutRef.current) { if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current); clearTimeout(saveTimeoutRef.current);
// saveDraft returns the new draft ID after destroy+create try {
const savedId = await saveDraft(); const savedId = await saveDraft();
if (savedId) { if (savedId) {
finalDraftId = savedId; finalDraftId = savedId;
} }
} catch (err) {
debug.error('Failed to save draft before send:', err);
}
} }
// Get the selected identity or primary identity
const currentIdentity = selectedIdentityId const currentIdentity = selectedIdentityId
? identities.find(id => id.id === selectedIdentityId) ? identities.find(id => id.id === selectedIdentityId)
: primaryIdentity; : primaryIdentity;
// Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email const fromEmail = currentIdentity?.email
? subAddressTag ? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag) ? generateSubAddress(currentIdentity.email, subAddressTag)
: currentIdentity.email : currentIdentity.email
: undefined; : undefined;
onSend?.({ try {
await onSend?.({
to: toAddresses, to: toAddresses,
cc: ccAddresses, cc: ccAddresses,
bcc: bccAddresses, bcc: bccAddresses,
@@ -435,7 +487,6 @@ export function EmailComposer({
identityId: currentIdentity?.id, identityId: currentIdentity?.id,
}); });
// Reset form
setTo(""); setTo("");
setCc(""); setCc("");
setBcc(""); setBcc("");
@@ -443,21 +494,27 @@ export function EmailComposer({
setBody(""); setBody("");
setDraftId(null); setDraftId(null);
setSubAddressTag(""); setSubAddressTag("");
setValidationErrors({});
} catch (err) {
debug.error('Failed to send email:', err);
toast.error(t('send_failed'));
} }
}; };
const handleClose = () => { const handleClose = async () => {
// If there's a draft with content, ask user if they want to discard
if (draftId && (to || subject || body)) { if (draftId && (to || subject || body)) {
const confirmDiscard = window.confirm(t('discard_draft_confirm')); const confirmed = await confirm({
title: t('discard_draft_title'),
message: t('discard_draft_confirm'),
confirmText: t('discard'),
variant: "destructive",
});
if (confirmDiscard) { if (confirmed) {
// Clear any pending auto-save
if (saveTimeoutRef.current) { if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current); clearTimeout(saveTimeoutRef.current);
} }
// Delete the draft if callback is provided
if (onDiscardDraft) { if (onDiscardDraft) {
onDiscardDraft(draftId); onDiscardDraft(draftId);
} }
@@ -555,7 +612,7 @@ export function EmailComposer({
</div> </div>
</div> </div>
<div className="flex items-center gap-2 relative"> <div className={cn("flex items-center gap-2 relative", shakeField === 'to' && "animate-shake")}>
<span className="text-sm text-muted-foreground w-16">{t('to')}:</span> <span className="text-sm text-muted-foreground w-16">{t('to')}:</span>
<div className="flex-1 relative"> <div className="flex-1 relative">
<Input <Input
@@ -565,19 +622,27 @@ export function EmailComposer({
value={to} value={to}
onChange={(e) => { onChange={(e) => {
setTo(e.target.value); setTo(e.target.value);
if (validationErrors.to) setValidationErrors(prev => ({ ...prev, to: false }));
handleAutocomplete(e.target.value, 'to'); handleAutocomplete(e.target.value, 'to');
}} }}
onKeyDown={(e) => handleAutoKeyDown(e, 'to')} onKeyDown={(e) => handleAutoKeyDown(e, 'to')}
onBlur={() => setTimeout(() => { if (activeAutoField === 'to') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)} onBlur={(e) => handleAutoBlur(e, 'to')}
className="border-0 focus-visible:ring-0" className={cn(
"border-0 focus-visible:ring-0",
validationErrors.to && "ring-2 ring-red-500 dark:ring-red-400"
)}
role="combobox" role="combobox"
aria-expanded={activeAutoField === 'to' && autocompleteResults.length > 0} aria-expanded={activeAutoField === 'to' && autocompleteResults.length > 0}
aria-autocomplete="list" aria-autocomplete="list"
aria-controls={activeAutoField === 'to' ? 'autocomplete-to' : undefined} aria-controls={activeAutoField === 'to' ? 'autocomplete-to' : undefined}
aria-activedescendant={activeAutoField === 'to' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined} aria-activedescendant={activeAutoField === 'to' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
aria-invalid={validationErrors.to || undefined}
/> />
{validationErrors.to && (
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5 px-1">{t('validation.recipient_required')}</p>
)}
{activeAutoField === 'to' && autocompleteResults.length > 0 && ( {activeAutoField === 'to' && autocompleteResults.length > 0 && (
<AutocompleteDropdown id="autocomplete-to" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'to')} /> <AutocompleteDropdown ref={toDropdownRef} id="autocomplete-to" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'to')} />
)} )}
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
@@ -614,7 +679,7 @@ export function EmailComposer({
handleAutocomplete(e.target.value, 'cc'); handleAutocomplete(e.target.value, 'cc');
}} }}
onKeyDown={(e) => handleAutoKeyDown(e, 'cc')} onKeyDown={(e) => handleAutoKeyDown(e, 'cc')}
onBlur={() => setTimeout(() => { if (activeAutoField === 'cc') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)} onBlur={(e) => handleAutoBlur(e, 'cc')}
className="border-0 focus-visible:ring-0" className="border-0 focus-visible:ring-0"
role="combobox" role="combobox"
aria-expanded={activeAutoField === 'cc' && autocompleteResults.length > 0} aria-expanded={activeAutoField === 'cc' && autocompleteResults.length > 0}
@@ -623,7 +688,7 @@ export function EmailComposer({
aria-activedescendant={activeAutoField === 'cc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined} aria-activedescendant={activeAutoField === 'cc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
/> />
{activeAutoField === 'cc' && autocompleteResults.length > 0 && ( {activeAutoField === 'cc' && autocompleteResults.length > 0 && (
<AutocompleteDropdown id="autocomplete-cc" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'cc')} /> <AutocompleteDropdown ref={ccDropdownRef} id="autocomplete-cc" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'cc')} />
)} )}
</div> </div>
</div> </div>
@@ -643,7 +708,7 @@ export function EmailComposer({
handleAutocomplete(e.target.value, 'bcc'); handleAutocomplete(e.target.value, 'bcc');
}} }}
onKeyDown={(e) => handleAutoKeyDown(e, 'bcc')} onKeyDown={(e) => handleAutoKeyDown(e, 'bcc')}
onBlur={() => setTimeout(() => { if (activeAutoField === 'bcc') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)} onBlur={(e) => handleAutoBlur(e, 'bcc')}
className="border-0 focus-visible:ring-0" className="border-0 focus-visible:ring-0"
role="combobox" role="combobox"
aria-expanded={activeAutoField === 'bcc' && autocompleteResults.length > 0} aria-expanded={activeAutoField === 'bcc' && autocompleteResults.length > 0}
@@ -652,7 +717,7 @@ export function EmailComposer({
aria-activedescendant={activeAutoField === 'bcc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined} aria-activedescendant={activeAutoField === 'bcc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
/> />
{activeAutoField === 'bcc' && autocompleteResults.length > 0 && ( {activeAutoField === 'bcc' && autocompleteResults.length > 0 && (
<AutocompleteDropdown id="autocomplete-bcc" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'bcc')} /> <AutocompleteDropdown ref={bccDropdownRef} id="autocomplete-bcc" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'bcc')} />
)} )}
</div> </div>
</div> </div>
@@ -664,22 +729,35 @@ export function EmailComposer({
type="text" type="text"
placeholder={t('subject_placeholder')} placeholder={t('subject_placeholder')}
value={subject} value={subject}
onChange={(e) => setSubject(e.target.value)} onChange={(e) => {
className="flex-1 border-0 focus-visible:ring-0" setSubject(e.target.value);
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
}}
className={cn(
"flex-1 border-0 focus-visible:ring-0",
validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400"
)}
aria-invalid={validationErrors.subject || undefined}
/> />
</div> </div>
</div> </div>
<div className="flex-1 px-4 py-3 min-h-0"> <div className="flex-1 px-4 py-3 min-h-0">
<textarea <textarea
className="w-full h-full resize-none outline-none text-sm bg-transparent text-foreground placeholder:text-muted-foreground" className={cn(
"w-full h-full resize-none outline-none text-sm bg-transparent text-foreground placeholder:text-muted-foreground rounded",
validationErrors.body && "ring-2 ring-red-500 dark:ring-red-400"
)}
placeholder={t('body_placeholder')} placeholder={t('body_placeholder')}
value={body} value={body}
onChange={(e) => setBody(e.target.value)} onChange={(e) => {
setBody(e.target.value);
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
}}
aria-invalid={validationErrors.body || undefined}
/> />
</div> </div>
{/* Attachments display */}
{attachments.length > 0 && ( {attachments.length > 0 && (
<div className="px-4 py-2 border-t"> <div className="px-4 py-2 border-t">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -687,28 +765,37 @@ export function EmailComposer({
<div <div
key={index} key={index}
className={cn( className={cn(
"flex items-center gap-2 px-3 py-1 rounded-md text-sm", "relative flex items-center gap-2 px-3 py-1.5 rounded-md text-sm overflow-hidden",
att.error ? "bg-red-500/10 text-red-600 dark:text-red-400" : "bg-muted text-foreground" att.error ? "bg-red-500/10 text-red-600 dark:text-red-400" : "bg-muted text-foreground"
)} )}
> >
{att.uploading && (
<div className="absolute inset-0 pointer-events-none">
<div className="h-full bg-primary/10 animate-pulse" />
<div className="absolute bottom-0 left-0 h-0.5 bg-primary/40 animate-[indeterminate_1.5s_ease-in-out_infinite]" style={{ width: '40%' }} />
</div>
)}
<div className="relative flex items-center gap-2">
{att.uploading ? ( {att.uploading ? (
<Loader2 className="w-3 h-3 animate-spin" /> <Loader2 className="w-3 h-3 animate-spin flex-shrink-0" />
) : att.error ? ( ) : att.error ? (
<AlertCircle className="w-3 h-3" /> <AlertCircle className="w-3 h-3 flex-shrink-0" />
) : ( ) : (
<Paperclip className="w-3 h-3" /> <Paperclip className="w-3 h-3 flex-shrink-0" />
)} )}
<span className="max-w-[200px] truncate">{att.file.name}</span> <span className="max-w-[200px] truncate">{att.file.name}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground whitespace-nowrap">
({(att.file.size / 1024).toFixed(1)} {t('file_size_kb')}) ({formatFileSize(att.file.size)})
</span> </span>
<button <button
onClick={() => removeAttachment(index)} onClick={() => removeAttachment(index)}
className="ml-1 hover:text-red-500" className="ml-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center"
title={att.uploading ? t('upload_cancel') : undefined}
> >
<X className="w-3 h-3" /> <X className="w-3 h-3" />
</button> </button>
</div> </div>
</div>
))} ))}
</div> </div>
</div> </div>
@@ -759,7 +846,11 @@ export function EmailComposer({
<Paperclip className="w-4 h-4 mr-2" /> <Paperclip className="w-4 h-4 mr-2" />
{t('attach')} {t('attach')}
</Button> </Button>
<Button onClick={handleSend}> <Button
onClick={handleSend}
disabled={!canSend}
title={getSendTooltip()}
>
<Send className="w-4 h-4 mr-2" /> <Send className="w-4 h-4 mr-2" />
{t('send')} {t('send')}
</Button> </Button>
@@ -801,23 +892,20 @@ export function EmailComposer({
</div> </div>
</div> </div>
)} )}
<ConfirmDialog {...confirmDialogProps} />
</div> </div>
); );
} }
function AutocompleteDropdown({ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
id,
results,
selectedIndex,
onSelect,
}: {
id: string; id: string;
results: Array<{ name: string; email: string }>; results: Array<{ name: string; email: string }>;
selectedIndex: number; selectedIndex: number;
onSelect: (email: string) => void; onSelect: (email: string) => void;
}) { }>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect }, ref) {
return ( return (
<div id={id} role="listbox" className="absolute top-full left-0 right-0 z-50 mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto"> <div ref={ref} id={id} role="listbox" className="absolute top-full left-0 right-0 z-50 mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto">
{results.map((r, i) => ( {results.map((r, i) => (
<button <button
key={i} key={i}
@@ -842,4 +930,4 @@ function AutocompleteDropdown({
))} ))}
</div> </div>
); );
} });
+26 -3
View File
@@ -273,20 +273,43 @@ export function EmailContextMenu({
{/* Set color submenu - only for single email */} {/* Set color submenu - only for single email */}
{!showBatchActions && ( {!showBatchActions && (
<ContextMenuSubMenu icon={Palette} label={t("color_tag")}> <ContextMenuSubMenu icon={Palette} label={t("color_tag")}>
<div className="px-3 py-2 flex flex-wrap gap-1.5"> <div
{colorOptions.map((option) => ( className="px-3 py-2 flex flex-wrap gap-2"
role="group"
aria-label={t("color_tag")}
onKeyDown={(e) => {
const buttons = Array.from(
e.currentTarget.querySelectorAll<HTMLButtonElement>("button")
);
const idx = buttons.indexOf(e.target as HTMLButtonElement);
if (idx < 0) return;
let next = -1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
next = (idx + 1) % buttons.length;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
next = (idx - 1 + buttons.length) % buttons.length;
}
if (next >= 0) {
e.preventDefault();
buttons[next].focus();
}
}}
>
{colorOptions.map((option, i) => (
<button <button
key={option.value} key={option.value}
tabIndex={i === 0 ? 0 : -1}
onClick={() => onClick={() =>
handleAction(() => onSetColorTag?.(option.value)) handleAction(() => onSetColorTag?.(option.value))
} }
className={cn( className={cn(
"w-6 h-6 rounded-full hover:scale-110 transition-transform", "w-8 h-8 rounded-full hover:scale-110 transition-transform focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
option.color, option.color,
currentColor === option.value && currentColor === option.value &&
"ring-2 ring-offset-2 ring-offset-background ring-foreground" "ring-2 ring-offset-2 ring-offset-background ring-foreground"
)} )}
title={option.name} title={option.name}
aria-label={option.name}
/> />
))} ))}
</div> </div>
+1 -1
View File
@@ -100,7 +100,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
<button <button
onClick={handleCheckboxClick} onClick={handleCheckboxClick}
className={cn( className={cn(
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200", "p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110", "hover:bg-muted/50 hover:scale-110",
"active:scale-95", "active:scale-95",
isChecked && "text-primary" isChecked && "text-primary"
+15 -1
View File
@@ -7,11 +7,13 @@ import { cn } from "@/lib/utils";
import { Inbox, CheckSquare, Square, Trash2, Mail, MailOpen, Loader2 } from "lucide-react"; import { Inbox, CheckSquare, Square, Trash2, Mail, MailOpen, Loader2 } from "lucide-react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils"; import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { SearchChips } from "@/components/search/search-chips"; import { SearchChips } from "@/components/search/search-chips";
@@ -90,6 +92,7 @@ export function EmailList({
}, [emails]); }, [emails]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>(); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
@@ -143,7 +146,16 @@ export function EmailList({
}; };
const handleBatchDelete = async () => { const handleBatchDelete = async () => {
if (!client || isProcessing || !confirm(`Delete ${selectedEmailIds.size} emails?`)) return; if (!client || isProcessing) return;
const confirmed = await confirmDialog({
title: t('batch_actions.delete_confirm_title'),
message: t('batch_actions.delete_confirm_message', { count: selectedEmailIds.size }),
confirmText: t('batch_actions.delete'),
variant: "destructive",
});
if (!confirmed) return;
setIsProcessing(true); setIsProcessing(true);
try { try {
await batchDelete(client); await batchDelete(client);
@@ -456,6 +468,8 @@ export function EmailList({
}} }}
/> />
)} )}
<ConfirmDialog {...confirmDialogProps} />
</div> </div>
); );
} }
+40 -17
View File
@@ -47,6 +47,7 @@ import {
Copy, Copy,
Brain, Brain,
Sparkles, Sparkles,
Keyboard,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
@@ -63,7 +64,7 @@ import { findCalendarAttachment } from "@/lib/calendar-invitation";
interface EmailViewerProps { interface EmailViewerProps {
email: Email | null; email: Email | null;
isLoading?: boolean; isLoading?: boolean;
onReply?: () => void; onReply?: (draftText?: string) => void;
onReplyAll?: () => void; onReplyAll?: () => void;
onForward?: () => void; onForward?: () => void;
onDelete?: () => void; onDelete?: () => void;
@@ -76,6 +77,7 @@ interface EmailViewerProps {
onMarkAsSpam?: () => void; onMarkAsSpam?: () => void;
onUndoSpam?: () => void; onUndoSpam?: () => void;
onBack?: () => void; onBack?: () => void;
onShowShortcuts?: () => void;
currentUserEmail?: string; currentUserEmail?: string;
currentUserName?: string; currentUserName?: string;
currentMailboxRole?: string; currentMailboxRole?: string;
@@ -170,6 +172,7 @@ export function EmailViewer({
onMarkAsSpam, onMarkAsSpam,
onUndoSpam, onUndoSpam,
onBack, onBack,
onShowShortcuts,
currentUserEmail, currentUserEmail,
currentUserName, currentUserName,
currentMailboxRole, currentMailboxRole,
@@ -456,6 +459,11 @@ export function EmailViewer({
} }
} }
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
if (resolvedTheme === 'dark') { if (resolvedTheme === 'dark') {
if (htmlNode.style) { if (htmlNode.style) {
const originalStyles = htmlNode.style.cssText; const originalStyles = htmlNode.style.cssText;
@@ -512,7 +520,7 @@ export function EmailViewer({
.replace(/\r/g, '<br>') // Old Mac line endings .replace(/\r/g, '<br>') // Old Mac line endings
.replace(/\n/g, '<br>') // Unix line endings .replace(/\n/g, '<br>') // Unix line endings
.replace(/\t/g, '&nbsp;&nbsp;&nbsp;&nbsp;') // Convert tabs to spaces .replace(/\t/g, '&nbsp;&nbsp;&nbsp;&nbsp;') // Convert tabs to spaces
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>'); // Don't match across tags .replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
return { return {
html: htmlFromText, html: htmlFromText,
@@ -650,7 +658,7 @@ export function EmailViewer({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onBack} onClick={onBack}
className="h-10 w-10 flex-shrink-0 -ml-2" className="h-11 w-11 lg:h-10 lg:w-10 flex-shrink-0 -ml-2"
aria-label={t('back_to_list')} aria-label={t('back_to_list')}
> >
<ChevronLeft className="w-5 h-5" /> <ChevronLeft className="w-5 h-5" />
@@ -697,9 +705,9 @@ export function EmailViewer({
)} )}
{/* Primary Reply Button */} {/* Primary Reply Button */}
<Button <Button
onClick={onReply} onClick={() => onReply?.()}
size="sm" size="sm"
className="mr-1 h-8 lg:h-9" className="mr-1 h-10 lg:h-9"
title={t('tooltips.reply')} title={t('tooltips.reply')}
> >
<Reply className="w-4 h-4" /> <Reply className="w-4 h-4" />
@@ -711,7 +719,7 @@ export function EmailViewer({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 hover:bg-muted" className="h-10 w-10 lg:h-8 lg:w-8 hover:bg-muted"
title={t('more_reply_options')} title={t('more_reply_options')}
> >
<ChevronDown className="w-4 h-4 text-muted-foreground" /> <ChevronDown className="w-4 h-4 text-muted-foreground" />
@@ -740,7 +748,7 @@ export function EmailViewer({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onArchive} onClick={onArchive}
className="h-8 w-8 hover:bg-muted hidden lg:flex" className="h-10 w-10 lg:h-8 lg:w-8 hover:bg-muted hidden lg:flex"
title={t('tooltips.archive')} title={t('tooltips.archive')}
> >
<Archive className="w-4 h-4 text-muted-foreground" /> <Archive className="w-4 h-4 text-muted-foreground" />
@@ -753,7 +761,7 @@ export function EmailViewer({
size="icon" size="icon"
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam} onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
className={cn( className={cn(
"hidden h-8 w-8 lg:flex", "hidden h-10 w-10 lg:h-8 lg:w-8 lg:flex",
isInJunkFolder isInJunkFolder
? "hover:bg-green-50 dark:hover:bg-green-950/30" ? "hover:bg-green-50 dark:hover:bg-green-950/30"
: "hover:bg-red-50 dark:hover:bg-red-950/30" : "hover:bg-red-50 dark:hover:bg-red-950/30"
@@ -772,7 +780,7 @@ export function EmailViewer({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onDelete} onClick={onDelete}
className="h-8 w-8 hover:bg-muted" className="h-10 w-10 lg:h-8 lg:w-8 hover:bg-muted"
title={t('tooltips.delete')} title={t('tooltips.delete')}
> >
<Trash2 className="w-4 h-4 text-muted-foreground" /> <Trash2 className="w-4 h-4 text-muted-foreground" />
@@ -781,7 +789,7 @@ export function EmailViewer({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onToggleStar} onClick={onToggleStar}
className="h-8 w-8 hover:bg-muted hidden lg:flex" className="h-10 w-10 lg:h-8 lg:w-8 hover:bg-muted hidden lg:flex"
title={isStarred ? "Unstar" : "Star"} title={isStarred ? "Unstar" : "Star"}
> >
<Star className={cn( <Star className={cn(
@@ -855,7 +863,7 @@ export function EmailViewer({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 hover:bg-muted" className="h-10 w-10 lg:h-8 lg:w-8 hover:bg-muted"
title={t('more_actions')} title={t('more_actions')}
> >
<MoreVertical className="w-4 h-4 text-muted-foreground" /> <MoreVertical className="w-4 h-4 text-muted-foreground" />
@@ -875,6 +883,15 @@ export function EmailViewer({
<Printer className="w-4 h-4" /> <Printer className="w-4 h-4" />
{t('print')} {t('print')}
</button> </button>
{onShowShortcuts && (
<button
onClick={onShowShortcuts}
className="w-full px-3 py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
>
<Keyboard className="w-4 h-4" />
{t('keyboard_shortcuts')}
</button>
)}
{/* Separator */} {/* Separator */}
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
{/* Spam action - contextual */} {/* Spam action - contextual */}
@@ -1315,7 +1332,7 @@ export function EmailViewer({
<div className="flex flex-col gap-3 isolate"> <div className="flex flex-col gap-3 isolate">
{/* External Content Controls */} {/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && ( {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
<div className="flex items-center gap-3 flex-wrap md:justify-center"> <div className="flex items-center gap-3 flex-wrap md:justify-center rounded-md px-3 py-1 bg-muted/50 dark:bg-muted/30">
{externalContentPolicy === 'ask' && ( {externalContentPolicy === 'ask' && (
<button <button
onClick={() => setAllowExternalContent(true)} onClick={() => setAllowExternalContent(true)}
@@ -1344,7 +1361,7 @@ export function EmailViewer({
{/* Unsubscribe Controls */} {/* Unsubscribe Controls */}
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<div className="flex items-center md:justify-center"> <div className="flex items-center md:justify-center rounded-md px-3 py-1 bg-blue-50/50 dark:bg-blue-950/20">
<UnsubscribeBanner <UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe} listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''} senderEmail={email?.from?.[0]?.email || ''}
@@ -1360,7 +1377,9 @@ export function EmailViewer({
{/* Calendar Invitation Banner */} {/* Calendar Invitation Banner */}
{hasCalendarInvitation && ( {hasCalendarInvitation && (
<div className="rounded-md px-3 py-1 bg-amber-50/50 dark:bg-amber-950/20">
<CalendarInvitationBanner email={email} /> <CalendarInvitationBanner email={email} />
</div>
)} )}
</div> </div>
</div> </div>
@@ -1503,7 +1522,7 @@ export function EmailViewer({
"hover:border-accent focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary transition-all", "hover:border-accent focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary transition-all",
"resize-none" "resize-none"
)} )}
rows={isQuickReplyFocused || quickReplyText ? 3 : 1} rows={isQuickReplyFocused || quickReplyText ? 3 : 2}
disabled={isSendingQuickReply} disabled={isSendingQuickReply}
/> />
@@ -1511,7 +1530,7 @@ export function EmailViewer({
{(isQuickReplyFocused || quickReplyText) && ( {(isQuickReplyFocused || quickReplyText) && (
<div className="flex items-center justify-between gap-2 animate-in fade-in slide-in-from-top-1 duration-200"> <div className="flex items-center justify-between gap-2 animate-in fade-in slide-in-from-top-1 duration-200">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{quickReplyText.length > 0 && t('characters_count', { count: quickReplyText.length })} {t('characters_count', { count: quickReplyText.length })}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
@@ -1528,7 +1547,11 @@ export function EmailViewer({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onReply} onClick={() => {
onReply?.(quickReplyText);
setQuickReplyText("");
setIsQuickReplyFocused(false);
}}
disabled={isSendingQuickReply} disabled={isSendingQuickReply}
className="text-muted-foreground" className="text-muted-foreground"
> >
@@ -1605,7 +1628,7 @@ export function EmailViewer({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setShowSourceModal(false)} onClick={() => setShowSourceModal(false)}
className="h-8 w-8" className="h-10 w-10 lg:h-8 lg:w-8"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</Button> </Button>
@@ -297,6 +297,11 @@ function EmailCard({
} }
} }
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
if (resolvedTheme === 'dark') { if (resolvedTheme === 'dark') {
if (htmlNode.style) { if (htmlNode.style) {
const originalStyles = htmlNode.style.cssText; const originalStyles = htmlNode.style.cssText;
+10 -4
View File
@@ -5,7 +5,7 @@ import { formatDate } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types"; import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2 } from "lucide-react"; import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare } from "lucide-react";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { getThreadColorTag } from "@/lib/thread-utils"; import { getThreadColorTag } from "@/lib/thread-utils";
@@ -244,6 +244,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
"active:scale-95", "active:scale-95",
"text-muted-foreground hover:text-foreground" "text-muted-foreground hover:text-foreground"
)} )}
aria-expanded={isExpanded}
aria-label={t('toggle_thread')}
> >
{isLoading ? ( {isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
@@ -279,12 +281,16 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}> )}>
{participantNames.join(", ")} {participantNames.join(", ")}
</span> </span>
<span className={cn( <span
"flex-shrink-0 px-1.5 py-0.5 text-xs rounded-full font-medium", className={cn(
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
hasUnread hasUnread
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground" : "bg-muted text-muted-foreground"
)}> )}
title={t('messages_tooltip', { count: emailCount })}
>
<MessageSquare className="w-3 h-3" />
{emailCount} {emailCount}
</span> </span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
+13 -4
View File
@@ -5,12 +5,14 @@ import { useTranslations } from 'next-intl';
import { X, Mail, Pencil, Trash2, Plus, AlertTriangle } from 'lucide-react'; import { X, Mail, Pencil, Trash2, Plus, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { IdentityForm } from './identity-form'; import { IdentityForm } from './identity-form';
import { useIdentityStore } from '@/stores/identity-store'; import { useIdentityStore } from '@/stores/identity-store';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import type { Identity, EmailAddress } from '@/lib/jmap/types'; import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { toast } from '@/stores/toast-store'; import { toast } from '@/stores/toast-store';
import { useFocusTrap } from '@/hooks/use-focus-trap'; import { useFocusTrap } from '@/hooks/use-focus-trap';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
interface IdentityFormData { interface IdentityFormData {
name: string; name: string;
@@ -36,6 +38,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
// Focus trap with Escape handling // Focus trap with Escape handling
const modalRef = useFocusTrap({ const modalRef = useFocusTrap({
@@ -117,9 +120,13 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
return; return;
} }
if (!window.confirm(t('delete_confirm'))) { const confirmed = await confirmDialog({
return; title: t('delete_confirm_title'),
} message: t('delete_confirm'),
confirmText: t('delete_button'),
variant: "destructive",
});
if (!confirmed) return;
setDeletingId(identity.id); setDeletingId(identity.id);
@@ -133,7 +140,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
} finally { } finally {
setDeletingId(null); setDeletingId(null);
} }
}, [client, removeIdentity, t, tNotif]); }, [client, removeIdentity, t, tNotif, confirmDialog]);
if (!isOpen) return null; if (!isOpen) return null;
@@ -297,6 +304,8 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
</div> </div>
</div> </div>
</div> </div>
<ConfirmDialog {...confirmDialogProps} />
</div> </div>
); );
} }
+17 -31
View File
@@ -1,10 +1,10 @@
"use client"; "use client";
import { useEffect, useRef } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { X, Keyboard } from "lucide-react"; import { X, Keyboard } from "lucide-react";
import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts"; import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useFocusTrap } from "@/hooks/use-focus-trap";
interface KeyboardShortcutsModalProps { interface KeyboardShortcutsModalProps {
isOpen: boolean; isOpen: boolean;
@@ -13,43 +13,28 @@ interface KeyboardShortcutsModalProps {
export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) { export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) {
const t = useTranslations(); const t = useTranslations();
const modalRef = useRef<HTMLDivElement>(null);
// Close on any key press const modalRef = useFocusTrap({
useEffect(() => { isActive: isOpen,
const handleKeyDown = () => { onEscape: onClose,
onClose(); restoreFocus: true,
}; });
if (isOpen) {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}
}, [isOpen, onClose]);
// Close on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [isOpen, onClose]);
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150"> <div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div <div
ref={modalRef} ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-dialog-title"
className={cn( className={cn(
"bg-background border border-border rounded-lg shadow-xl", "bg-background border border-border rounded-lg shadow-xl",
"w-full max-w-2xl max-h-[80vh] overflow-hidden", "w-full max-w-2xl max-h-[90vh] overflow-hidden",
"animate-in zoom-in-95 duration-200" "animate-in zoom-in-95 duration-200"
)} )}
> >
@@ -57,20 +42,21 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
<div className="flex items-center justify-between px-6 py-4 border-b border-border"> <div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Keyboard className="w-5 h-5 text-muted-foreground" /> <Keyboard className="w-5 h-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground"> <h2 id="shortcuts-dialog-title" className="text-lg font-semibold text-foreground">
{t("shortcuts.title")} {t("shortcuts.title")}
</h2> </h2>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="p-2 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground" className="p-2 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
aria-label={t("common.close")}
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(80vh-80px)]"> <div className="p-4 md:p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Navigation Section */} {/* Navigation Section */}
<section> <section>
+8 -4
View File
@@ -50,8 +50,12 @@ export function MobileHeader({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={handleLeftAction} onClick={handleLeftAction}
className="h-10 w-10" className={cn(
"h-11 w-11",
!showBack && sidebarOpen && "bg-accent"
)}
aria-label={showBack ? "Go back" : "Toggle menu"} aria-label={showBack ? "Go back" : "Toggle menu"}
aria-expanded={!showBack ? sidebarOpen : undefined}
> >
{showBack ? ( {showBack ? (
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
@@ -73,7 +77,7 @@ export function MobileHeader({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onSearch} onClick={onSearch}
className="h-10 w-10" className="h-11 w-11"
aria-label={t('mobile.search')} aria-label={t('mobile.search')}
> >
<Search className="h-5 w-5" /> <Search className="h-5 w-5" />
@@ -84,7 +88,7 @@ export function MobileHeader({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onCompose} onClick={onCompose}
className="h-10 w-10 text-primary" className="h-11 w-11 text-primary"
aria-label={t('mobile.compose')} aria-label={t('mobile.compose')}
> >
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
@@ -127,7 +131,7 @@ export function MobileViewerHeader({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onBack} onClick={onBack}
className="h-10 w-10" className="h-11 w-11"
aria-label={t('mobile.go_back')} aria-label={t('mobile.go_back')}
> >
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { Mail, Calendar, BookUser, Settings } from "lucide-react";
import { usePathname, Link } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store";
import { useEmailStore } from "@/stores/email-store";
import { cn } from "@/lib/utils";
interface NavItem {
id: string;
icon: typeof Mail;
labelKey: string;
href: string;
hidden?: boolean;
badge?: number;
}
interface NavigationRailProps {
orientation?: "vertical" | "horizontal";
collapsed?: boolean;
className?: string;
}
export function NavigationRail({
orientation = "vertical",
collapsed = false,
className,
}: NavigationRailProps) {
const t = useTranslations("sidebar");
const pathname = usePathname();
const { supportsCalendar } = useCalendarStore();
const { mailboxes } = useEmailStore();
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const navItems: NavItem[] = [
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
{ id: "settings", icon: Settings, labelKey: "settings", href: "/settings" },
];
const visibleItems = navItems.filter((item) => !item.hidden);
const getIsActive = (href: string) => {
if (href === "/") {
return pathname === "/" || pathname === "";
}
return pathname.startsWith(href);
};
if (orientation === "horizontal") {
return (
<nav
className={cn("flex items-center justify-around bg-background border-t border-border", className)}
role="navigation"
aria-label={t("nav_label")}
>
{visibleItems.map((item) => {
const isActive = getIsActive(item.href);
const Icon = item.icon;
return (
<Link
key={item.id}
href={item.href}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
"transition-colors duration-150",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)}
aria-current={isActive ? "page" : undefined}
>
<div className="relative">
<Icon className="w-5 h-5" />
{item.badge != null && item.badge > 0 && (
<span className="absolute -top-1.5 -right-2.5 flex items-center justify-center min-w-[16px] h-4 text-[10px] font-bold rounded-full bg-red-500 text-white px-1">
{item.badge > 99 ? "99+" : item.badge}
</span>
)}
{isActive && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)}
</div>
<span className="text-[10px] font-medium leading-tight">{t(item.labelKey)}</span>
</Link>
);
})}
</nav>
);
}
return (
<nav
className={cn(
"flex flex-col",
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
className
)}
role="navigation"
aria-label={t("nav_label")}
>
{visibleItems.map((item) => {
const isActive = getIsActive(item.href);
const Icon = item.icon;
return (
<Link
key={item.id}
href={item.href}
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
collapsed
? "justify-center w-10 h-10"
: "px-2.5 py-1.5 text-sm",
"max-lg:min-h-[44px]",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
aria-current={isActive ? "page" : undefined}
title={collapsed ? t(item.labelKey) : undefined}
>
<Icon className={cn("w-[18px] h-[18px] flex-shrink-0", isActive && "text-primary")} />
{!collapsed && <span className="truncate">{t(item.labelKey)}</span>}
{item.badge != null && item.badge > 0 && (
<span className={cn(
"absolute flex items-center justify-center min-w-[16px] h-4 text-[10px] font-bold rounded-full bg-red-500 text-white px-1",
collapsed ? "-top-0.5 -right-0.5" : "right-1.5"
)}>
{item.badge > 99 ? "99+" : item.badge}
</span>
)}
</Link>
);
})}
</nav>
);
}
+84 -150
View File
@@ -20,14 +20,11 @@ import {
ChevronDown, ChevronDown,
Folder, Folder,
FolderOpen, FolderOpen,
Settings,
ChevronUp,
Users, Users,
User, User,
BookUser,
Palmtree, Palmtree,
SlidersHorizontal, SlidersHorizontal,
Calendar, Settings,
X, X,
} from "lucide-react"; } from "lucide-react";
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils"; import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
@@ -37,8 +34,8 @@ import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { activeFilterCount } from "@/lib/jmap/search-utils"; import { activeFilterCount } from "@/lib/jmap/search-utils";
import { useVacationStore } from "@/stores/vacation-store"; import { useVacationStore } from "@/stores/vacation-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
interface SidebarProps { interface SidebarProps {
mailboxes: Mailbox[]; mailboxes: Mailbox[];
@@ -55,27 +52,22 @@ interface SidebarProps {
className?: string; className?: string;
} }
// Map role to icon
const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => { const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => {
const lowerName = name?.toLowerCase() || ""; const lowerName = name?.toLowerCase() || "";
// Shared folders root node
if (id === 'shared-folders-root') { if (id === 'shared-folders-root') {
return isExpanded ? FolderOpen : Users; return isExpanded ? FolderOpen : Users;
} }
// Shared account nodes
if (id?.startsWith('shared-account-')) { if (id?.startsWith('shared-account-')) {
return isExpanded ? FolderOpen : User; return isExpanded ? FolderOpen : User;
} }
// Shared mailboxes (but not virtual nodes)
if (isShared && hasChildren && !id?.startsWith('shared-')) { if (isShared && hasChildren && !id?.startsWith('shared-')) {
return isExpanded ? FolderOpen : Folder; return isExpanded ? FolderOpen : Folder;
} }
if (hasChildren) { if (hasChildren) {
// For folders with children, return open/closed folder icon
return isExpanded ? FolderOpen : Folder; return isExpanded ? FolderOpen : Folder;
} }
@@ -85,10 +77,9 @@ const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean,
if (role === "trash" || lowerName.includes("trash")) return Trash2; if (role === "trash" || lowerName.includes("trash")) return Trash2;
if (role === "archive" || lowerName.includes("archive")) return Archive; if (role === "archive" || lowerName.includes("archive")) return Archive;
if (lowerName.includes("star") || lowerName.includes("flag")) return Star; if (lowerName.includes("star") || lowerName.includes("flag")) return Star;
return Inbox; // Default icon return Inbox;
}; };
// Component for rendering a single mailbox node with its children
function MailboxTreeItem({ function MailboxTreeItem({
node, node,
selectedMailbox, selectedMailbox,
@@ -109,10 +100,9 @@ function MailboxTreeItem({
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const isExpanded = expandedFolders.has(node.id); const isExpanded = expandedFolders.has(node.id);
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id); const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
const indentPixels = node.depth * 16; // 16px per depth level const indentPixels = node.depth * 16;
const isVirtualNode = node.id.startsWith('shared-'); // Virtual nodes for shared folder organization const isVirtualNode = node.id.startsWith('shared-');
// Drag and drop functionality
const { isDragging: globalDragging } = useDragDropContext(); const { isDragging: globalDragging } = useDragDropContext();
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({ const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
mailbox: node, mailbox: node,
@@ -140,15 +130,16 @@ function MailboxTreeItem({
{...(globalDragging ? dropHandlers : {})} {...(globalDragging ? dropHandlers : {})}
className={cn( className={cn(
"group w-full flex items-center px-2 py-1 lg:py-1 max-lg:py-3 max-lg:min-h-[44px] text-sm transition-all duration-200", "group w-full flex items-center px-2 py-1 lg:py-1 max-lg:py-3 max-lg:min-h-[44px] text-sm transition-all duration-200",
selectedMailbox === node.id isVirtualNode
? "text-muted-foreground"
: selectedMailbox === node.id
? "bg-accent text-accent-foreground" ? "bg-accent text-accent-foreground"
: "hover:bg-muted text-foreground", : "hover:bg-muted text-foreground",
node.depth === 0 && "font-medium", node.depth === 0 && !isVirtualNode && "font-medium",
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset", isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset",
isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50" isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50"
)} )}
> >
{/* Expand/Collapse Chevron */}
{hasChildren && ( {hasChildren && (
<button <button
onClick={(e) => { onClick={(e) => {
@@ -170,14 +161,13 @@ function MailboxTreeItem({
</button> </button>
)} )}
{/* Mailbox Button */}
<button <button
onClick={() => !isVirtualNode && onMailboxSelect?.(node.id)} onClick={() => !isVirtualNode && onMailboxSelect?.(node.id)}
disabled={isVirtualNode} disabled={isVirtualNode}
className={cn( className={cn(
"flex-1 flex items-center text-left py-1 lg:py-1 max-lg:py-2 px-1 rounded", "flex-1 flex items-center text-left py-1 lg:py-1 max-lg:py-2 px-1 rounded",
"transition-colors duration-150", "transition-colors duration-150",
isVirtualNode && "cursor-default" isVirtualNode && "cursor-default select-none"
)} )}
style={{ style={{
paddingLeft: hasChildren ? '4px' : `${indentPixels + 24}px` paddingLeft: hasChildren ? '4px' : `${indentPixels + 24}px`
@@ -189,7 +179,7 @@ function MailboxTreeItem({
hasChildren && isExpanded && "text-primary", hasChildren && isExpanded && "text-primary",
selectedMailbox === node.id && "text-accent-foreground", selectedMailbox === node.id && "text-accent-foreground",
!hasChildren && node.depth > 0 && "text-muted-foreground", !hasChildren && node.depth > 0 && "text-muted-foreground",
node.isShared && "text-blue-500" // Shared folders in blue node.isShared && "text-blue-500"
)} /> )} />
{!isCollapsed && ( {!isCollapsed && (
<> <>
@@ -209,7 +199,6 @@ function MailboxTreeItem({
</button> </button>
</div> </div>
{/* Render children if expanded */}
{hasChildren && isExpanded && !isCollapsed && ( {hasChildren && isExpanded && !isCollapsed && (
<div className="relative"> <div className="relative">
{node.children.map((child) => ( {node.children.map((child) => (
@@ -229,27 +218,26 @@ function MailboxTreeItem({
); );
} }
function VacationIndicator() { function VacationBanner() {
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
const router = useRouter();
const { isEnabled, isSupported } = useVacationStore(); const { isEnabled, isSupported } = useVacationStore();
if (!isSupported || !isEnabled) return null; if (!isSupported || !isEnabled) return null;
return ( return (
<span <button
className="relative group" onClick={() => router.push('/settings')}
title={t("vacation_active")} className={cn(
"flex items-center gap-2 w-full px-3 py-2 text-xs",
"bg-amber-500/10 dark:bg-amber-400/10 text-amber-700 dark:text-amber-400",
"hover:bg-amber-500/15 dark:hover:bg-amber-400/15 transition-colors"
)}
> >
<Palmtree className="w-3.5 h-3.5 text-amber-500 dark:text-amber-400" /> <Palmtree className="w-3.5 h-3.5 flex-shrink-0" />
<span className={cn( <span className="truncate font-medium">{t("vacation_active")}</span>
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1", <Settings className="w-3 h-3 ml-auto flex-shrink-0 opacity-60" />
"bg-popover text-popover-foreground text-xs rounded shadow-lg", </button>
"whitespace-nowrap opacity-0 group-hover:opacity-100",
"pointer-events-none transition-opacity duration-200 z-50"
)}>
{t("vacation_active")}
</span>
</span>
); );
} }
@@ -280,6 +268,43 @@ function AdvancedSearchToggle() {
); );
} }
function StorageQuota({ quota, isCollapsed }: { quota: { used: number; total: number } | null; isCollapsed: boolean }) {
const t = useTranslations('sidebar');
if (!quota || quota.total <= 0) return null;
const usagePercent = Math.min((quota.used / quota.total) * 100, 100);
const barColor = usagePercent > 90
? "bg-red-500 dark:bg-red-400"
: usagePercent > 70
? "bg-amber-500 dark:bg-amber-400"
: "bg-green-500 dark:bg-green-400";
if (isCollapsed) {
return (
<div className="px-2 py-2" title={`${formatFileSize(quota.used)} / ${formatFileSize(quota.total)}`}>
<div className="w-full bg-muted rounded-full h-1">
<div className={cn(barColor, "h-1 rounded-full transition-all")} style={{ width: `${usagePercent}%` }} />
</div>
</div>
);
}
return (
<div className="px-3 py-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{t("storage")}</span>
<span className="text-foreground tabular-nums">
{formatFileSize(quota.used)} / {formatFileSize(quota.total)}
</span>
</div>
<div className="mt-1 w-full bg-muted rounded-full h-1">
<div className={cn(barColor, "h-1 rounded-full transition-all")} style={{ width: `${usagePercent}%` }} />
</div>
</div>
);
}
export function Sidebar({ export function Sidebar({
mailboxes = [], mailboxes = [],
selectedMailbox = "", selectedMailbox = "",
@@ -297,17 +322,12 @@ export function Sidebar({
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set()); const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [showMenu, setShowMenu] = useState(false);
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
const { supportsCalendar } = useCalendarStore();
// Sync local search query with store's active search query
useEffect(() => { useEffect(() => {
setSearchQuery(activeSearchQuery); setSearchQuery(activeSearchQuery);
}, [activeSearchQuery]); }, [activeSearchQuery]);
const router = useRouter();
// Load expanded folders from localStorage on mount
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem('expandedMailboxes'); const stored = localStorage.getItem('expandedMailboxes');
if (stored) { if (stored) {
@@ -315,10 +335,9 @@ export function Sidebar({
const parsed = JSON.parse(stored); const parsed = JSON.parse(stored);
setExpandedFolders(new Set(parsed)); setExpandedFolders(new Set(parsed));
} catch (e) { } catch (e) {
console.error('Failed to parse expanded mailboxes:', e); debug.error('Failed to parse expanded mailboxes:', e);
} }
} else { } else {
// By default, expand root folders that have children
const tree = buildMailboxTree(mailboxes); const tree = buildMailboxTree(mailboxes);
const defaultExpanded = tree const defaultExpanded = tree
.filter(node => node.children.length > 0) .filter(node => node.children.length > 0)
@@ -327,7 +346,6 @@ export function Sidebar({
} }
}, [mailboxes]); }, [mailboxes]);
// Save expanded folders to localStorage when changed
const handleToggleExpand = (mailboxId: string) => { const handleToggleExpand = (mailboxId: string) => {
setExpandedFolders((prev) => { setExpandedFolders((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -336,7 +354,9 @@ export function Sidebar({
} else { } else {
next.add(mailboxId); next.add(mailboxId);
} }
try {
localStorage.setItem('expandedMailboxes', JSON.stringify(Array.from(next))); localStorage.setItem('expandedMailboxes', JSON.stringify(Array.from(next)));
} catch { /* storage full or unavailable */ }
return next; return next;
}); });
}; };
@@ -348,15 +368,12 @@ export function Sidebar({
} }
}; };
// Build hierarchical mailbox tree
const mailboxTree = buildMailboxTree(mailboxes); const mailboxTree = buildMailboxTree(mailboxes);
// Keyboard navigation
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (!selectedMailbox || isCollapsed) return; if (!selectedMailbox || isCollapsed) return;
// Find the selected node in the tree
const findNode = (nodes: MailboxNode[]): MailboxNode | null => { const findNode = (nodes: MailboxNode[]): MailboxNode | null => {
for (const node of nodes) { for (const node of nodes) {
if (node.id === selectedMailbox) return node; if (node.id === selectedMailbox) return node;
@@ -369,14 +386,11 @@ export function Sidebar({
const selectedNode = findNode(mailboxTree); const selectedNode = findNode(mailboxTree);
if (!selectedNode) return; if (!selectedNode) return;
// Handle arrow keys for expand/collapse
if (e.key === 'ArrowRight' && selectedNode.children.length > 0) { if (e.key === 'ArrowRight' && selectedNode.children.length > 0) {
// Expand folder
if (!expandedFolders.has(selectedMailbox)) { if (!expandedFolders.has(selectedMailbox)) {
handleToggleExpand(selectedMailbox); handleToggleExpand(selectedMailbox);
} }
} else if (e.key === 'ArrowLeft' && selectedNode.children.length > 0) { } else if (e.key === 'ArrowLeft' && selectedNode.children.length > 0) {
// Collapse folder
if (expandedFolders.has(selectedMailbox)) { if (expandedFolders.has(selectedMailbox)) {
handleToggleExpand(selectedMailbox); handleToggleExpand(selectedMailbox);
} }
@@ -399,7 +413,6 @@ export function Sidebar({
> >
{/* Header */} {/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border"> <div className="flex items-center gap-2 px-4 py-3 border-b border-border">
{/* Mobile/Tablet: Close button */}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -410,7 +423,6 @@ export function Sidebar({
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</Button> </Button>
{/* Desktop: Collapse toggle */}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -421,13 +433,16 @@ export function Sidebar({
</Button> </Button>
{!isCollapsed && ( {!isCollapsed && (
<Button onClick={onCompose} className="flex-1"> <Button onClick={onCompose} className="flex-1" title={t("compose_hint")}>
<PenSquare className="w-4 h-4 mr-2" /> <PenSquare className="w-4 h-4 mr-2" />
{t("compose")} {t("compose")}
</Button> </Button>
)} )}
</div> </div>
{/* Vacation Banner */}
{!isCollapsed && <VacationBanner />}
{/* Search + Advanced Filter Toggle */} {/* Search + Advanced Filter Toggle */}
{!isCollapsed && ( {!isCollapsed && (
<div className="px-4 py-3"> <div className="px-4 py-3">
@@ -436,7 +451,7 @@ export function Sidebar({
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
type="text" type="text"
placeholder={t("search_placeholder")} placeholder={t("search_placeholder_hint")}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className={cn("pl-9", searchQuery && "pr-8")} className={cn("pl-9", searchQuery && "pr-8")}
@@ -470,7 +485,6 @@ export function Sidebar({
</div> </div>
) : ( ) : (
<> <>
{/* Render hierarchical mailbox tree */}
{mailboxTree.map((node) => ( {mailboxTree.map((node) => (
<MailboxTreeItem <MailboxTreeItem
key={node.id} key={node.id}
@@ -487,102 +501,29 @@ export function Sidebar({
</div> </div>
</div> </div>
{/* Footer */} {/* Footer: Storage Quota + Sign Out + Push Status */}
{!isCollapsed && ( <div className="border-t border-border">
<> <StorageQuota quota={quota ?? null} isCollapsed={isCollapsed} />
{/* Sliding Menu Panel */}
<div className={cn( <div className={cn(
"absolute bottom-0 left-0 right-0 bg-background border-t border-border z-10 shadow-lg", "flex items-center border-t border-border",
"transform transition-all duration-300 ease-out", isCollapsed ? "justify-center py-2" : "justify-between px-3 py-2"
showMenu ? "-translate-y-12" : "translate-y-full"
)}> )}>
<div className="py-2">
{/* Storage Info */}
{quota && quota.total > 0 && (
<div className="px-4 py-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{t("storage")}</span>
<span className="text-foreground">
{formatFileSize(quota.used)} / {formatFileSize(quota.total)}
</span>
</div>
<div className="mt-1 w-full bg-muted rounded-full h-1">
<div
className="bg-primary h-1 rounded-full"
style={{ width: `${Math.min((quota.used / quota.total) * 100, 100)}%` }}
/>
</div>
</div>
)}
<div className="border-t border-border mt-2 pt-2">
{/* Contacts */}
<button
onClick={() => router.push('/contacts')}
className="w-full px-4 py-2 flex items-center justify-between hover:bg-muted transition-colors text-sm"
>
<span className="flex items-center gap-2">
<BookUser className="w-4 h-4" />
{t("contacts")}
</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
{/* Calendar */}
{supportsCalendar && (
<button
onClick={() => router.push('/calendar')}
className="w-full px-4 py-2 flex items-center justify-between hover:bg-muted transition-colors text-sm"
>
<span className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
{t("calendar")}
</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
)}
{/* Settings */}
<button
onClick={() => router.push('/settings')}
className="w-full px-4 py-2 flex items-center justify-between hover:bg-muted transition-colors text-sm"
>
<span className="flex items-center gap-2">
<Settings className="w-4 h-4" />
{t("settings")}
</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
{/* Sign Out */}
{onLogout && ( {onLogout && (
<button <button
onClick={onLogout} onClick={onLogout}
className="w-full px-4 py-2 flex items-center gap-2 hover:bg-muted transition-colors text-sm" className={cn(
"flex items-center gap-2 rounded-md transition-colors text-sm text-muted-foreground hover:text-foreground hover:bg-muted",
isCollapsed ? "p-2" : "px-2 py-1.5"
)}
title={t("sign_out")}
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
{t("sign_out")} {!isCollapsed && t("sign_out")}
</button> </button>
)} )}
</div>
</div>
</div>
{/* Menu Toggle Button */} {!isCollapsed && (
<div className="border-t border-border relative">
<button
onClick={() => setShowMenu(!showMenu)}
className={cn(
"w-full px-4 py-3 flex items-center justify-between",
"hover:bg-muted transition-colors",
"text-sm text-foreground"
)}
>
<span className="flex items-center gap-2">
<Menu className="w-4 h-4" />
Menu
<VacationIndicator />
{/* Push Connection Status Indicator */}
<span <span
className="relative group" className="relative group"
title={isPushConnected ? t("push_connected") : t("push_disconnected")} title={isPushConnected ? t("push_connected") : t("push_disconnected")}
@@ -593,7 +534,6 @@ export function Sidebar({
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40" isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
)} )}
/> />
{/* Tooltip on hover */}
<span className={cn( <span className={cn(
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1", "absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1",
"bg-popover text-popover-foreground text-xs rounded shadow-lg", "bg-popover text-popover-foreground text-xs rounded shadow-lg",
@@ -603,15 +543,9 @@ export function Sidebar({
{isPushConnected ? t("push_connected") : t("push_disconnected")} {isPushConnected ? t("push_connected") : t("push_disconnected")}
</span> </span>
</span> </span>
</span>
<ChevronUp className={cn(
"w-4 h-4 transition-transform duration-200",
showMenu ? "" : "rotate-180"
)} />
</button>
</div>
</>
)} )}
</div> </div>
</div>
</div>
); );
} }
+3 -1
View File
@@ -62,7 +62,9 @@ export function VacationSettings() {
warnings.push(t('warnings.end_before_start')); warnings.push(t('warnings.end_before_start'));
} }
if (localFromDate && new Date(localFromDate) < new Date()) { const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
if (localFromDate && new Date(localFromDate) < todayStart) {
warnings.push(t('warnings.start_in_past')); warnings.push(t('warnings.start_in_past'));
} }
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useEffect, useId } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: "default" | "destructive";
}
export function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText,
cancelText,
variant = "default",
}: ConfirmDialogProps) {
const t = useTranslations("confirm_dialog");
const id = useId();
const dialogRef = useFocusTrap({
isActive: isOpen,
onEscape: onClose,
restoreFocus: true,
});
useEffect(() => {
if (!isOpen) return;
const handleBackdropClick = (e: MouseEvent) => {
if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", handleBackdropClick);
return () => document.removeEventListener("mousedown", handleBackdropClick);
}, [isOpen, onClose, dialogRef]);
if (!isOpen) return null;
const resolvedConfirmText = confirmText || t("confirm");
const resolvedCancelText = cancelText || t("cancel");
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[2px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
<div
ref={dialogRef}
role="alertdialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-message`}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<div className="p-6">
<div className="flex items-start gap-4">
{variant === "destructive" && (
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-destructive" />
</div>
)}
<div className="flex-1 min-w-0">
<h2
id={`${id}-title`}
className="text-lg font-semibold text-foreground"
>
{title}
</h2>
<p
id={`${id}-message`}
className="mt-2 text-sm text-muted-foreground"
>
{message}
</p>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={onClose}>
{resolvedCancelText}
</Button>
<Button
variant={variant === "destructive" ? "destructive" : "default"}
onClick={() => {
try {
onConfirm();
} finally {
onClose();
}
}}
className={cn(
variant === "destructive" && "shadow-sm"
)}
>
{resolvedConfirmText}
</Button>
</div>
</div>
</div>
);
}
+32 -4
View File
@@ -6,6 +6,11 @@ import { cn } from "@/lib/utils";
export type ToastType = "success" | "error" | "info" | "warning"; export type ToastType = "success" | "error" | "info" | "warning";
export interface ToastAction {
label: string;
onClick: () => void;
}
export interface Toast { export interface Toast {
id: string; id: string;
type: ToastType; type: ToastType;
@@ -14,6 +19,7 @@ export interface Toast {
duration?: number; duration?: number;
onClick?: () => void; onClick?: () => void;
icon?: React.ReactNode; icon?: React.ReactNode;
action?: ToastAction;
} }
interface ToastProps { interface ToastProps {
@@ -52,22 +58,39 @@ export function ToastItem({ toast, onClose }: ToastProps) {
className={cn( className={cn(
"flex items-start gap-3 p-4 rounded-lg border shadow-lg bg-background animate-slide-in", "flex items-start gap-3 p-4 rounded-lg border shadow-lg bg-background animate-slide-in",
styles[toast.type], styles[toast.type],
toast.onClick && "cursor-pointer hover:opacity-90 transition-opacity" toast.onClick && !toast.action && "cursor-pointer hover:opacity-90 transition-opacity"
)} )}
onClick={() => { onClick={() => {
if (toast.onClick) { if (toast.onClick && !toast.action) {
toast.onClick(); toast.onClick();
onClose(toast.id); onClose(toast.id);
} }
}} }}
> >
{toast.icon !== undefined ? toast.icon : <Icon className="w-5 h-5 flex-shrink-0 mt-0.5" />} {toast.icon !== undefined ? toast.icon : <Icon className="w-5 h-5 flex-shrink-0 mt-0.5" />}
<div className="flex-1"> <div className="flex-1 min-w-0">
<h4 className="font-medium">{toast.title}</h4> <h4 className="font-medium">{toast.title}</h4>
{toast.message && ( {toast.message && (
<p className="text-sm mt-1 opacity-90">{toast.message}</p> <p className="text-sm mt-1 opacity-90">{toast.message}</p>
)} )}
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0">
{toast.action && (
<button
onClick={(e) => {
e.stopPropagation();
try {
toast.action!.onClick();
onClose(toast.id);
} catch {
// Don't close toast on error so user can retry
}
}}
className="text-sm font-medium underline underline-offset-2 hover:opacity-80 transition-opacity whitespace-nowrap"
>
{toast.action.label}
</button>
)}
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -78,12 +101,17 @@ export function ToastItem({ toast, onClose }: ToastProps) {
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
</div>
); );
} }
export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) { export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
return ( return (
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm"> <div
className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm"
role="status"
aria-live="polite"
>
{toasts.map((toast) => ( {toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onClose={onClose} /> <ToastItem key={toast.id} toast={toast} onClose={onClose} />
))} ))}
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { X, Lightbulb } from "lucide-react";
import { Button } from "@/components/ui/button";
const ONBOARDING_KEY = "onboarding_completed";
export function WelcomeBanner() {
const t = useTranslations("welcome");
const [visible, setVisible] = useState(false);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
try {
if (!localStorage.getItem(ONBOARDING_KEY)) {
setVisible(true);
}
} catch { /* localStorage unavailable */ }
}, []);
const dismiss = useCallback(() => {
setDismissed(true);
try {
localStorage.setItem(ONBOARDING_KEY, "true");
} catch { /* localStorage unavailable */ }
}, []);
useEffect(() => {
if (!visible) return;
const handle = (e: KeyboardEvent) => {
if (e.key === "Escape") dismiss();
};
window.addEventListener("keydown", handle);
return () => window.removeEventListener("keydown", handle);
}, [visible, dismiss]);
if (!visible) return null;
return (
<div
role="complementary"
aria-label={t("title")}
className={`mx-4 mt-3 mb-1 rounded-lg border border-border bg-background shadow-sm transition-all duration-300 ease-out ${
dismissed ? "opacity-0 scale-95 pointer-events-none" : "opacity-100 scale-100"
}`}
onTransitionEnd={() => {
if (dismissed) setVisible(false);
}}
>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5 p-1.5 rounded-md bg-primary/10">
<Lightbulb className="w-4 h-4 text-primary" />
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium text-foreground">
{t("title")}
</h3>
<ul className="space-y-1.5 text-sm text-muted-foreground">
<li>{t("tip_compose")}</li>
<li>{t("tip_shortcuts")}</li>
<li>{t("tip_sidebar")}</li>
<li>{t("tip_settings")}</li>
</ul>
</div>
</div>
<button
onClick={dismiss}
className="flex-shrink-0 p-1 rounded hover:bg-muted transition-colors"
aria-label={t("dismiss")}
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
<div className="mt-3 flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={dismiss}
className="text-xs"
>
{t("got_it")}
</Button>
</div>
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { useState, useCallback, useRef, useEffect } from "react";
interface ConfirmDialogState {
isOpen: boolean;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant: "default" | "destructive";
onConfirm: () => void;
}
const INITIAL_STATE: ConfirmDialogState = {
isOpen: false,
title: "",
message: "",
variant: "default",
onConfirm: () => {},
};
interface ConfirmOptions {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: "default" | "destructive";
}
export function useConfirmDialog() {
const [state, setState] = useState<ConfirmDialogState>(INITIAL_STATE);
const resolveRef = useRef<((value: boolean) => void) | null>(null);
useEffect(() => {
return () => {
if (resolveRef.current) {
resolveRef.current(false);
resolveRef.current = null;
}
};
}, []);
const confirm = useCallback(
(options: ConfirmOptions): Promise<boolean> => {
return new Promise((resolve) => {
resolveRef.current = resolve;
setState({
isOpen: true,
title: options.title,
message: options.message,
confirmText: options.confirmText,
cancelText: options.cancelText,
variant: options.variant || "default",
onConfirm: () => {
resolveRef.current = null;
resolve(true);
},
});
});
},
[]
);
const close = useCallback(() => {
if (resolveRef.current) {
resolveRef.current(false);
resolveRef.current = null;
}
setState(INITIAL_STATE);
}, []);
return {
dialogProps: {
isOpen: state.isOpen,
onClose: close,
onConfirm: state.onConfirm,
title: state.title,
message: state.message,
confirmText: state.confirmText,
cancelText: state.cancelText,
variant: state.variant,
},
confirm,
};
}
+1 -1
View File
@@ -8,7 +8,7 @@ import DOMPurify from 'dompurify';
*/ */
export const EMAIL_SANITIZE_CONFIG = { export const EMAIL_SANITIZE_CONFIG = {
ADD_TAGS: [], ADD_TAGS: [],
ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'], ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
ALLOW_DATA_ATTR: false, ALLOW_DATA_ATTR: false,
FORCE_BODY: true, FORCE_BODY: true,
FORBID_TAGS: [ FORBID_TAGS: [
+88 -16
View File
@@ -12,7 +12,8 @@
"invalid_credentials": "Ungültige E-Mail-Adresse oder Passwort", "invalid_credentials": "Ungültige E-Mail-Adresse oder Passwort",
"connection_failed": "Verbindung zum Server fehlgeschlagen", "connection_failed": "Verbindung zum Server fehlgeschlagen",
"generic": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.", "generic": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
"totp_invalid": "Ungültiger Authentifizierungscode. Überprüfen Sie Ihre Authenticator-App." "totp_invalid": "Ungültiger Authentifizierungscode. Überprüfen Sie Ihre Authenticator-App.",
"server_error": "Der Server ist vorübergehend nicht erreichbar. Bitte versuchen Sie es später erneut."
}, },
"config_error": { "config_error": {
"title": "Konfigurationsfehler", "title": "Konfigurationsfehler",
@@ -23,7 +24,13 @@
"totp_toggle": "Ich habe Zwei-Faktor-Authentifizierung", "totp_toggle": "Ich habe Zwei-Faktor-Authentifizierung",
"totp_label": "Authentifizierungscode", "totp_label": "Authentifizierungscode",
"totp_placeholder": "000000", "totp_placeholder": "000000",
"totp_hide": "Zwei-Faktor-Authentifizierung ausblenden" "totp_hide": "Zwei-Faktor-Authentifizierung ausblenden",
"show_password": "Passwort anzeigen",
"hide_password": "Passwort verbergen",
"totp_hint": "Aktivieren Sie dies, wenn Ihr Konto einen Zwei-Faktor-Code erfordert",
"totp_checkbox": "Zwei-Faktor-Authentifizierungscode verwenden",
"session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
"dismiss": "Schließen"
}, },
"sidebar": { "sidebar": {
"close": "Schließen", "close": "Schließen",
@@ -66,7 +73,11 @@
"go_back": "Zurück" "go_back": "Zurück"
}, },
"clear_search": "Suche löschen", "clear_search": "Suche löschen",
"vacation_active": "Abwesenheitsnotiz ist aktiv" "vacation_active": "Abwesenheitsnotiz ist aktiv",
"compose_hint": "Verfassen (c)",
"search_placeholder_hint": "E-Mails suchen... (drücke /)",
"mail": "E-Mail",
"nav_label": "Navigation"
}, },
"email_list": { "email_list": {
"no_emails": "Keine E-Mails", "no_emails": "Keine E-Mails",
@@ -202,7 +213,12 @@
"tooltips": { "tooltips": {
"reply": "Antworten", "reply": "Antworten",
"archive": "Archivieren", "archive": "Archivieren",
"delete": "Löschen" "delete": "Löschen",
"reply_all": "Allen antworten (a)",
"forward": "Weiterleiten (f)",
"star": "Markieren (s)",
"unstar": "Markierung entfernen (s)",
"compose": "Verfassen (c)"
}, },
"spam": { "spam": {
"button_title": "Spam melden", "button_title": "Spam melden",
@@ -242,7 +258,8 @@
"no_calendar": "Kalender nicht verfügbar", "no_calendar": "Kalender nicht verfügbar",
"select_calendar": "Kalender auswählen", "select_calendar": "Kalender auswählen",
"already_in_calendar": "Bereits in deinem Kalender" "already_in_calendar": "Bereits in deinem Kalender"
} },
"keyboard_shortcuts": "Tastaturkürzel (?)"
}, },
"email_composer": { "email_composer": {
"new_message": "Neue Nachricht", "new_message": "Neue Nachricht",
@@ -290,7 +307,17 @@
}, },
"remove_sub_address": "Sub-Adresse entfernen", "remove_sub_address": "Sub-Adresse entfernen",
"use_template": "Vorlage", "use_template": "Vorlage",
"save_as_template": "Als Vorlage speichern" "save_as_template": "Als Vorlage speichern",
"validation": {
"recipient_required": "Empfänger hinzufügen zum Senden",
"subject_required": "Betreff hinzufügen",
"body_required": "Nachricht schreiben oder Datei anhängen"
},
"upload_progress": "Hochladen {uploaded} / {total}",
"upload_cancel": "Hochladen abbrechen",
"upload_failed": "Hochladen von {filename} fehlgeschlagen",
"send_failed": "E-Mail konnte nicht gesendet werden",
"discard_draft_title": "Entwurf verwerfen?"
}, },
"common": { "common": {
"loading": "Lädt...", "loading": "Lädt...",
@@ -788,7 +815,12 @@
"rule_summary": { "rule_summary": {
"conditions_count": "{count, plural, one {# Bedingung} other {# Bedingungen}}", "conditions_count": "{count, plural, one {# Bedingung} other {# Bedingungen}}",
"actions_count": "{count, plural, one {# Aktion} other {# Aktionen}}" "actions_count": "{count, plural, one {# Aktion} other {# Aktionen}}"
} },
"templates_section": "Von Vorlage starten",
"template_newsletters": "Newsletter in Ordner verschieben",
"template_receipts": "Quittungen automatisch archivieren",
"template_important": "Wichtige E-Mails markieren",
"template_notifications": "Benachrichtigungen filtern"
}, },
"templates": { "templates": {
"title": "E-Mail-Vorlagen", "title": "E-Mail-Vorlagen",
@@ -822,7 +854,11 @@
"empty": "Vorlagenname ist erforderlich", "empty": "Vorlagenname ist erforderlich",
"too_long": "Vorlagenname darf maximal 200 Zeichen haben" "too_long": "Vorlagenname darf maximal 200 Zeichen haben"
} }
} },
"unsaved_changes": "Sie haben ungespeicherte Änderungen",
"discard_changes": "Änderungen verwerfen?",
"discard": "Verwerfen",
"keep_editing": "Weiter bearbeiten"
}, },
"errors": { "errors": {
"page_error_title": "Etwas ist schiefgelaufen", "page_error_title": "Etwas ist schiefgelaufen",
@@ -910,7 +946,9 @@
"archive": "Unterhaltung archivieren", "archive": "Unterhaltung archivieren",
"delete": "Unterhaltung löschen", "delete": "Unterhaltung löschen",
"star": "Unterhaltung mit Stern markieren", "star": "Unterhaltung mit Stern markieren",
"unstar": "Stern von Unterhaltung entfernen" "unstar": "Stern von Unterhaltung entfernen",
"messages_tooltip": "{count, plural, one {# Nachricht in dieser Unterhaltung} other {# Nachrichten in dieser Unterhaltung}}",
"toggle_thread": "Unterhaltung ein-/ausklappen"
}, },
"identities": { "identities": {
"modal_title": "Sendeidentitäten verwalten", "modal_title": "Sendeidentitäten verwalten",
@@ -1019,7 +1057,12 @@
"notes": "Notizen", "notes": "Notizen",
"no_contact_selected": "Kontakt auswählen, um Details anzuzeigen", "no_contact_selected": "Kontakt auswählen, um Details anzuzeigen",
"created": "Erstellt", "created": "Erstellt",
"updated": "Zuletzt aktualisiert" "updated": "Zuletzt aktualisiert",
"compose_email": "E-Mail schreiben",
"copy_email": "E-Mail kopieren",
"copy_phone": "Telefonnummer kopieren",
"copied": "In die Zwischenablage kopiert",
"copy_failed": "Kopieren in die Zwischenablage fehlgeschlagen"
}, },
"form": { "form": {
"create_title": "Neuer Kontakt", "create_title": "Neuer Kontakt",
@@ -1044,7 +1087,8 @@
"updating": "Wird aktualisiert...", "updating": "Wird aktualisiert...",
"name_required": "Mindestens ein Vor- oder Nachname ist erforderlich", "name_required": "Mindestens ein Vor- oder Nachname ist erforderlich",
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein", "email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"save_failed": "Kontakt konnte nicht gespeichert werden" "save_failed": "Kontakt konnte nicht gespeichert werden",
"email_error_inline": "Ungültiges E-Mail-Format"
}, },
"groups": { "groups": {
"create": "Neue Gruppe", "create": "Neue Gruppe",
@@ -1102,7 +1146,12 @@
"error_create": "Kontakt konnte nicht erstellt werden", "error_create": "Kontakt konnte nicht erstellt werden",
"error_update": "Kontakt konnte nicht aktualisiert werden", "error_update": "Kontakt konnte nicht aktualisiert werden",
"error_delete": "Kontakt konnte nicht gelöscht werden" "error_delete": "Kontakt konnte nicht gelöscht werden"
} },
"empty_state_title": "Keine Kontakte",
"empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei",
"empty_search_hint": "Versuchen Sie einen anderen Suchbegriff",
"clear_search": "Suche löschen",
"import_vcard": "vCard importieren"
}, },
"calendar": { "calendar": {
"title": "Kalender", "title": "Kalender",
@@ -1113,7 +1162,11 @@
"week": "Woche", "week": "Woche",
"day": "Tag", "day": "Tag",
"agenda": "Agenda", "agenda": "Agenda",
"today": "Heute" "today": "Heute",
"month_hint": "Monat (m)",
"week_hint": "Woche (w)",
"day_hint": "Tag (d)",
"agenda_hint": "Agenda (a)"
}, },
"events": { "events": {
"create": "Termin erstellen", "create": "Termin erstellen",
@@ -1159,7 +1212,10 @@
"you_organizer": "Sie sind der Organisator", "you_organizer": "Sie sind der Organisator",
"you_attendee": "Sie sind ein Teilnehmer", "you_attendee": "Sie sind ein Teilnehmer",
"no_participants": "Keine Teilnehmer", "no_participants": "Keine Teilnehmer",
"count": "{count, plural, one {# Teilnehmer} other {# Teilnehmer}}" "count": "{count, plural, one {# Teilnehmer} other {# Teilnehmer}}",
"invited_by": "Einladung von {name}",
"respond_below": "Antworten Sie mit den Schaltflächen unten",
"rsvp_label": "Ihre Antwort"
}, },
"recurrence": { "recurrence": {
"title": "Wiederholung", "title": "Wiederholung",
@@ -1249,7 +1305,8 @@
"error": "Kalender konnte nicht importiert werden", "error": "Kalender konnte nicht importiert werden",
"file_too_large": "Datei überschreitet das 5-MB-Limit", "file_too_large": "Datei überschreitet das 5-MB-Limit",
"invalid_format": "Ungültiges Kalenderdateiformat" "invalid_format": "Ungültiges Kalenderdateiformat"
} },
"mini_calendar_change": "Klicken, um den Monat zu wechseln"
}, },
"advanced_search": { "advanced_search": {
"title": "Erweiterte Suche", "title": "Erweiterte Suche",
@@ -1272,6 +1329,21 @@
"clear_all": "Alle löschen", "clear_all": "Alle löschen",
"filters_active": "{count} Filter", "filters_active": "{count} Filter",
"filters_active_plural": "{count} Filter", "filters_active_plural": "{count} Filter",
"toggle_filters": "Filter" "toggle_filters": "Filter",
"search_hint": "Verwenden Sie erweiterte Filter für eine präzise Suche",
"advanced_filters_tooltip": "Erweiterte Suchfilter"
},
"confirm_dialog": {
"confirm": "Bestätigen",
"cancel": "Abbrechen"
},
"welcome": {
"title": "Willkommen in Ihrem Postfach",
"tip_compose": "Drücken Sie c, um eine neue E-Mail zu verfassen",
"tip_shortcuts": "Drücken Sie ?, um alle Tastaturkürzel anzuzeigen",
"tip_sidebar": "Finden Sie Kalender, Kontakte und Einstellungen im Seitenmenü",
"tip_settings": "Passen Sie Ihre Erfahrung in den Einstellungen an",
"got_it": "Verstanden",
"dismiss": "Schließen"
} }
} }
+86 -14
View File
@@ -9,11 +9,15 @@
"signing_in": "Signing in...", "signing_in": "Signing in...",
"loading": "Loading...", "loading": "Loading...",
"error": { "error": {
"invalid_credentials": "Invalid email or password", "invalid_credentials": "Invalid email or password. Please check your credentials and try again.",
"connection_failed": "Failed to connect to the server", "connection_failed": "Unable to reach the server. Check your internet connection and try again.",
"generic": "An error occurred. Please try again.", "server_error": "The server is temporarily unavailable. Please try again later.",
"totp_invalid": "Invalid authentication code. Please check your authenticator app." "generic": "An unexpected error occurred. If this persists, contact your administrator.",
"totp_invalid": "Invalid authentication code. Please check your authenticator app and try again."
}, },
"show_password": "Show password",
"hide_password": "Hide password",
"totp_hint": "Enable this if your account requires a two-factor authentication code",
"config_error": { "config_error": {
"title": "Configuration Error", "title": "Configuration Error",
"fetch_failed": "Unable to load application configuration. Please try again later.", "fetch_failed": "Unable to load application configuration. Please try again later.",
@@ -23,12 +27,17 @@
"totp_toggle": "I have two-factor authentication", "totp_toggle": "I have two-factor authentication",
"totp_label": "Authentication code", "totp_label": "Authentication code",
"totp_placeholder": "000000", "totp_placeholder": "000000",
"totp_hide": "Hide two-factor authentication" "totp_hide": "Hide two-factor authentication",
"totp_checkbox": "Use two-factor authentication code",
"session_expired": "Your session has expired. Please sign in again.",
"dismiss": "Dismiss"
}, },
"sidebar": { "sidebar": {
"close": "Close", "close": "Close",
"compose": "Compose", "compose": "Compose",
"compose_hint": "Compose (c)",
"search_placeholder": "Search mail...", "search_placeholder": "Search mail...",
"search_placeholder_hint": "Search mail... (press /)",
"storage": "Storage", "storage": "Storage",
"sign_out": "Sign out", "sign_out": "Sign out",
"contacts": "Contacts", "contacts": "Contacts",
@@ -66,7 +75,9 @@
"go_back": "Go back" "go_back": "Go back"
}, },
"clear_search": "Clear search", "clear_search": "Clear search",
"vacation_active": "Vacation responder is active" "vacation_active": "Vacation responder is active",
"mail": "Mail",
"nav_label": "Navigation"
}, },
"email_list": { "email_list": {
"no_emails": "No emails", "no_emails": "No emails",
@@ -110,6 +121,7 @@
"mark_read": "Mark as read", "mark_read": "Mark as read",
"print": "Print", "print": "Print",
"view_source": "View source", "view_source": "View source",
"keyboard_shortcuts": "Keyboard shortcuts (?)",
"email_source": "Email Source", "email_source": "Email Source",
"copy_source": "Copy to clipboard", "copy_source": "Copy to clipboard",
"source_copied": "Source copied to clipboard", "source_copied": "Source copied to clipboard",
@@ -200,9 +212,14 @@
"none": "None" "none": "None"
}, },
"tooltips": { "tooltips": {
"reply": "Reply", "reply": "Reply (r)",
"archive": "Archive", "reply_all": "Reply All (a)",
"delete": "Delete" "forward": "Forward (f)",
"archive": "Archive (e)",
"delete": "Delete (# or Del)",
"star": "Star (s)",
"unstar": "Unstar (s)",
"compose": "Compose (c)"
}, },
"spam": { "spam": {
"button_title": "Report spam", "button_title": "Report spam",
@@ -262,6 +279,7 @@
"cancel": "Cancel", "cancel": "Cancel",
"attach": "Attach", "attach": "Attach",
"discard": "Discard", "discard": "Discard",
"discard_draft_title": "Discard draft?",
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?", "discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
"saving": "Saving...", "saving": "Saving...",
"draft_saved": "Draft saved", "draft_saved": "Draft saved",
@@ -290,7 +308,20 @@
}, },
"remove_sub_address": "Remove sub-address", "remove_sub_address": "Remove sub-address",
"use_template": "Template", "use_template": "Template",
"save_as_template": "Save as Template" "save_as_template": "Save as Template",
"validation": {
"recipient_required": "Add a recipient to send",
"subject_required": "Add a subject",
"body_required": "Write a message or attach a file"
},
"upload_progress": "Uploading {uploaded} / {total}",
"upload_cancel": "Cancel upload",
"upload_failed": "Failed to upload {filename}",
"send_failed": "Failed to send email"
},
"confirm_dialog": {
"confirm": "Confirm",
"cancel": "Cancel"
}, },
"common": { "common": {
"loading": "Loading...", "loading": "Loading...",
@@ -393,6 +424,10 @@
"import_success": "Settings imported successfully", "import_success": "Settings imported successfully",
"import_error": "Failed to import settings", "import_error": "Failed to import settings",
"reset_confirm": "Are you sure you want to reset all settings to defaults?", "reset_confirm": "Are you sure you want to reset all settings to defaults?",
"unsaved_changes": "You have unsaved changes",
"discard_changes": "Discard unsaved changes?",
"discard": "Discard",
"keep_editing": "Keep editing",
"tabs": { "tabs": {
"appearance": "Appearance", "appearance": "Appearance",
"language": "Language & Region", "language": "Language & Region",
@@ -771,6 +806,11 @@
"validation_empty_name": "Rule name is required", "validation_empty_name": "Rule name is required",
"validation_empty_conditions": "At least one condition with a value is required", "validation_empty_conditions": "At least one condition with a value is required",
"validation_empty_actions": "At least one action is required", "validation_empty_actions": "At least one action is required",
"templates_section": "Start from template",
"template_newsletters": "Move newsletters to folder",
"template_receipts": "Auto-archive receipts",
"template_important": "Flag important emails",
"template_notifications": "Filter notifications",
"sieve_editor": { "sieve_editor": {
"title": "Sieve Script Editor", "title": "Sieve Script Editor",
"warning": "Editing the raw Sieve script may break visual rule editing. Changes made here override the visual builder.", "warning": "Editing the raw Sieve script may break visual rule editing. Changes made here override the visual builder.",
@@ -902,6 +942,7 @@
"threads": { "threads": {
"messages_one": "{count} message", "messages_one": "{count} message",
"messages_other": "{count} messages", "messages_other": "{count} messages",
"messages_tooltip": "{count, plural, one {# message in this conversation} other {# messages in this conversation}}",
"expand": "Expand conversation", "expand": "Expand conversation",
"collapse": "Collapse conversation", "collapse": "Collapse conversation",
"loading": "Loading conversation...", "loading": "Loading conversation...",
@@ -910,7 +951,8 @@
"archive": "Archive conversation", "archive": "Archive conversation",
"delete": "Delete conversation", "delete": "Delete conversation",
"star": "Star conversation", "star": "Star conversation",
"unstar": "Unstar conversation" "unstar": "Unstar conversation",
"toggle_thread": "Toggle thread"
}, },
"identities": { "identities": {
"modal_title": "Manage Sending Identities", "modal_title": "Manage Sending Identities",
@@ -1003,7 +1045,12 @@
"search_placeholder": "Search contacts...", "search_placeholder": "Search contacts...",
"create_new": "New Contact", "create_new": "New Contact",
"empty_state": "No contacts yet", "empty_state": "No contacts yet",
"empty_state_title": "No contacts yet",
"empty_state_subtitle": "Create your first contact or import from a vCard file",
"empty_search": "No contacts match your search", "empty_search": "No contacts match your search",
"empty_search_hint": "Try a different search term",
"clear_search": "Clear search",
"import_vcard": "Import vCard",
"delete_confirm": "Are you sure you want to delete this contact?", "delete_confirm": "Are you sure you want to delete this contact?",
"local_mode": "Contacts are stored locally (server does not support JMAP Contacts)", "local_mode": "Contacts are stored locally (server does not support JMAP Contacts)",
"back_to_mail": "Back to mail", "back_to_mail": "Back to mail",
@@ -1018,6 +1065,11 @@
"addresses": "Addresses", "addresses": "Addresses",
"notes": "Notes", "notes": "Notes",
"no_contact_selected": "Select a contact to view details", "no_contact_selected": "Select a contact to view details",
"compose_email": "Compose email",
"copy_email": "Copy email",
"copy_phone": "Copy phone number",
"copied": "Copied to clipboard",
"copy_failed": "Failed to copy to clipboard",
"created": "Created", "created": "Created",
"updated": "Last updated" "updated": "Last updated"
}, },
@@ -1044,6 +1096,7 @@
"updating": "Updating...", "updating": "Updating...",
"name_required": "At least a first name or last name is required", "name_required": "At least a first name or last name is required",
"email_invalid": "Please enter a valid email address", "email_invalid": "Please enter a valid email address",
"email_error_inline": "Invalid email format",
"save_failed": "Failed to save contact" "save_failed": "Failed to save contact"
}, },
"groups": { "groups": {
@@ -1107,13 +1160,18 @@
"calendar": { "calendar": {
"title": "Calendar", "title": "Calendar",
"back_to_email": "Back to email", "back_to_email": "Back to email",
"my_calendars": "My calendars", "my_calendars": "Calendars",
"mini_calendar_change": "Click to change month",
"views": { "views": {
"month": "Month", "month": "Month",
"week": "Week", "week": "Week",
"day": "Day", "day": "Day",
"agenda": "Agenda", "agenda": "Agenda",
"today": "Today" "today": "Today",
"month_hint": "Month (m)",
"week_hint": "Week (w)",
"day_hint": "Day (d)",
"agenda_hint": "Agenda (a)"
}, },
"events": { "events": {
"create": "Create event", "create": "Create event",
@@ -1155,6 +1213,9 @@
"email_placeholder": "Add email address or search contacts", "email_placeholder": "Add email address or search contacts",
"send_invitations": "Send invitations to participants", "send_invitations": "Send invitations to participants",
"status_summary": "{accepted} accepted, {pending} pending", "status_summary": "{accepted} accepted, {pending} pending",
"invited_by": "Invitation from {name}",
"respond_below": "Respond using the buttons below",
"rsvp_label": "Your response",
"cancel_notification": "Participants will be notified of the cancellation", "cancel_notification": "Participants will be notified of the cancellation",
"you_organizer": "You are the organizer", "you_organizer": "You are the organizer",
"you_attendee": "You are an attendee", "you_attendee": "You are an attendee",
@@ -1272,6 +1333,17 @@
"clear_all": "Clear all", "clear_all": "Clear all",
"filters_active": "{count} filter", "filters_active": "{count} filter",
"filters_active_plural": "{count} filters", "filters_active_plural": "{count} filters",
"toggle_filters": "Filters" "toggle_filters": "Filters",
"search_hint": "Use advanced filters for precise search",
"advanced_filters_tooltip": "Advanced search filters"
},
"welcome": {
"title": "Welcome to your mailbox",
"tip_compose": "Press c to compose a new email",
"tip_shortcuts": "Press ? to see all keyboard shortcuts",
"tip_sidebar": "Find Calendar, Contacts and Settings in the sidebar menu",
"tip_settings": "Customize your experience in Settings",
"got_it": "Got it",
"dismiss": "Dismiss"
} }
} }
+88 -16
View File
@@ -12,7 +12,8 @@
"invalid_credentials": "Correo electrónico o contraseña inválidos", "invalid_credentials": "Correo electrónico o contraseña inválidos",
"connection_failed": "No se pudo conectar con el servidor", "connection_failed": "No se pudo conectar con el servidor",
"generic": "Ocurrió un error. Por favor, inténtelo de nuevo.", "generic": "Ocurrió un error. Por favor, inténtelo de nuevo.",
"totp_invalid": "Código de autenticación inválido. Verifica tu aplicación de autenticación." "totp_invalid": "Código de autenticación inválido. Verifica tu aplicación de autenticación.",
"server_error": "El servidor no está disponible temporalmente. Inténtalo más tarde."
}, },
"config_error": { "config_error": {
"title": "Error de Configuración", "title": "Error de Configuración",
@@ -23,7 +24,13 @@
"totp_toggle": "Tengo autenticación de dos factores", "totp_toggle": "Tengo autenticación de dos factores",
"totp_label": "Código de autenticación", "totp_label": "Código de autenticación",
"totp_placeholder": "000000", "totp_placeholder": "000000",
"totp_hide": "Ocultar autenticación de dos factores" "totp_hide": "Ocultar autenticación de dos factores",
"show_password": "Mostrar contraseña",
"hide_password": "Ocultar contraseña",
"totp_hint": "Activa si tu cuenta requiere un código de autenticación en dos pasos",
"totp_checkbox": "Usar código de autenticación en dos pasos",
"session_expired": "Tu sesión ha expirado. Inicia sesión de nuevo.",
"dismiss": "Cerrar"
}, },
"sidebar": { "sidebar": {
"close": "Cerrar", "close": "Cerrar",
@@ -66,7 +73,11 @@
"go_back": "Volver" "go_back": "Volver"
}, },
"clear_search": "Limpiar búsqueda", "clear_search": "Limpiar búsqueda",
"vacation_active": "Respuesta automática activa" "vacation_active": "Respuesta automática activa",
"compose_hint": "Redactar (c)",
"search_placeholder_hint": "Buscar correo... (pulsa /)",
"mail": "Correo",
"nav_label": "Navegación"
}, },
"email_list": { "email_list": {
"no_emails": "Sin correos", "no_emails": "Sin correos",
@@ -202,7 +213,12 @@
"tooltips": { "tooltips": {
"reply": "Responder", "reply": "Responder",
"archive": "Archivar", "archive": "Archivar",
"delete": "Eliminar" "delete": "Eliminar",
"reply_all": "Responder a todos (a)",
"forward": "Reenviar (f)",
"star": "Destacar (s)",
"unstar": "Quitar estrella (s)",
"compose": "Redactar (c)"
}, },
"spam": { "spam": {
"button_title": "Reportar spam", "button_title": "Reportar spam",
@@ -242,7 +258,8 @@
"no_calendar": "Calendario no disponible", "no_calendar": "Calendario no disponible",
"select_calendar": "Seleccionar calendario", "select_calendar": "Seleccionar calendario",
"already_in_calendar": "Ya está en tu calendario" "already_in_calendar": "Ya está en tu calendario"
} },
"keyboard_shortcuts": "Atajos de teclado (?)"
}, },
"email_composer": { "email_composer": {
"new_message": "Nuevo Mensaje", "new_message": "Nuevo Mensaje",
@@ -290,7 +307,17 @@
}, },
"remove_sub_address": "Eliminar sub-dirección", "remove_sub_address": "Eliminar sub-dirección",
"use_template": "Plantilla", "use_template": "Plantilla",
"save_as_template": "Guardar como plantilla" "save_as_template": "Guardar como plantilla",
"validation": {
"recipient_required": "Agregue un destinatario para enviar",
"subject_required": "Agregue un asunto",
"body_required": "Escriba un mensaje o adjunte un archivo"
},
"upload_progress": "Subiendo {uploaded} / {total}",
"upload_cancel": "Cancelar subida",
"upload_failed": "Error al subir {filename}",
"send_failed": "Error al enviar el correo",
"discard_draft_title": "¿Descartar borrador?"
}, },
"common": { "common": {
"loading": "Cargando...", "loading": "Cargando...",
@@ -788,7 +815,12 @@
"rule_summary": { "rule_summary": {
"conditions_count": "{count, plural, one {# condición} other {# condiciones}}", "conditions_count": "{count, plural, one {# condición} other {# condiciones}}",
"actions_count": "{count, plural, one {# acción} other {# acciones}}" "actions_count": "{count, plural, one {# acción} other {# acciones}}"
} },
"templates_section": "Usar una plantilla",
"template_newsletters": "Mover newsletters a carpeta",
"template_receipts": "Archivar recibos automáticamente",
"template_important": "Marcar correos importantes",
"template_notifications": "Filtrar notificaciones"
}, },
"templates": { "templates": {
"title": "Plantillas de correo", "title": "Plantillas de correo",
@@ -822,7 +854,11 @@
"empty": "El nombre de la plantilla es obligatorio", "empty": "El nombre de la plantilla es obligatorio",
"too_long": "El nombre de la plantilla no debe superar los 200 caracteres" "too_long": "El nombre de la plantilla no debe superar los 200 caracteres"
} }
} },
"unsaved_changes": "Tienes cambios sin guardar",
"discard_changes": "¿Descartar los cambios?",
"discard": "Descartar",
"keep_editing": "Seguir editando"
}, },
"errors": { "errors": {
"page_error_title": "Algo salió mal", "page_error_title": "Algo salió mal",
@@ -910,7 +946,9 @@
"archive": "Archivar conversación", "archive": "Archivar conversación",
"delete": "Eliminar conversación", "delete": "Eliminar conversación",
"star": "Destacar conversación", "star": "Destacar conversación",
"unstar": "Quitar destacado de conversación" "unstar": "Quitar destacado de conversación",
"messages_tooltip": "{count, plural, one {# mensaje en esta conversación} other {# mensajes en esta conversación}}",
"toggle_thread": "Expandir/contraer hilo"
}, },
"identities": { "identities": {
"modal_title": "Administrar Identidades de Envío", "modal_title": "Administrar Identidades de Envío",
@@ -1019,7 +1057,12 @@
"notes": "Notas", "notes": "Notas",
"no_contact_selected": "Selecciona un contacto para ver los detalles", "no_contact_selected": "Selecciona un contacto para ver los detalles",
"created": "Creado", "created": "Creado",
"updated": "Última actualización" "updated": "Última actualización",
"compose_email": "Escribir correo",
"copy_email": "Copiar correo",
"copy_phone": "Copiar teléfono",
"copied": "Copiado al portapapeles",
"copy_failed": "Error al copiar al portapapeles"
}, },
"form": { "form": {
"create_title": "Nuevo contacto", "create_title": "Nuevo contacto",
@@ -1044,7 +1087,8 @@
"updating": "Actualizando...", "updating": "Actualizando...",
"name_required": "Se requiere al menos un nombre o apellido", "name_required": "Se requiere al menos un nombre o apellido",
"email_invalid": "Introduce una dirección de correo válida", "email_invalid": "Introduce una dirección de correo válida",
"save_failed": "Error al guardar el contacto" "save_failed": "Error al guardar el contacto",
"email_error_inline": "Formato de correo inválido"
}, },
"groups": { "groups": {
"create": "Nuevo grupo", "create": "Nuevo grupo",
@@ -1102,7 +1146,12 @@
"error_create": "Error al crear el contacto", "error_create": "Error al crear el contacto",
"error_update": "Error al actualizar el contacto", "error_update": "Error al actualizar el contacto",
"error_delete": "Error al eliminar el contacto" "error_delete": "Error al eliminar el contacto"
} },
"empty_state_title": "Sin contactos",
"empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard",
"empty_search_hint": "Prueba con otro término de búsqueda",
"clear_search": "Borrar búsqueda",
"import_vcard": "Importar vCard"
}, },
"calendar": { "calendar": {
"title": "Calendario", "title": "Calendario",
@@ -1113,7 +1162,11 @@
"week": "Semana", "week": "Semana",
"day": "Día", "day": "Día",
"agenda": "Agenda", "agenda": "Agenda",
"today": "Hoy" "today": "Hoy",
"month_hint": "Mes (m)",
"week_hint": "Semana (w)",
"day_hint": "Día (d)",
"agenda_hint": "Agenda (a)"
}, },
"events": { "events": {
"create": "Crear evento", "create": "Crear evento",
@@ -1159,7 +1212,10 @@
"you_organizer": "Eres el organizador", "you_organizer": "Eres el organizador",
"you_attendee": "Eres un participante", "you_attendee": "Eres un participante",
"no_participants": "Sin participantes", "no_participants": "Sin participantes",
"count": "{count, plural, one {# participante} other {# participantes}}" "count": "{count, plural, one {# participante} other {# participantes}}",
"invited_by": "Invitación de {name}",
"respond_below": "Responde con los botones de abajo",
"rsvp_label": "Tu respuesta"
}, },
"recurrence": { "recurrence": {
"title": "Recurrencia", "title": "Recurrencia",
@@ -1249,7 +1305,8 @@
"error": "Error al importar el calendario", "error": "Error al importar el calendario",
"file_too_large": "El archivo supera el límite de 5 MB", "file_too_large": "El archivo supera el límite de 5 MB",
"invalid_format": "Formato de archivo de calendario no válido" "invalid_format": "Formato de archivo de calendario no válido"
} },
"mini_calendar_change": "Clic para cambiar de mes"
}, },
"advanced_search": { "advanced_search": {
"title": "Búsqueda avanzada", "title": "Búsqueda avanzada",
@@ -1272,6 +1329,21 @@
"clear_all": "Limpiar todo", "clear_all": "Limpiar todo",
"filters_active": "{count} filtro", "filters_active": "{count} filtro",
"filters_active_plural": "{count} filtros", "filters_active_plural": "{count} filtros",
"toggle_filters": "Filtros" "toggle_filters": "Filtros",
"search_hint": "Usa los filtros avanzados para una búsqueda precisa",
"advanced_filters_tooltip": "Filtros de búsqueda avanzados"
},
"confirm_dialog": {
"confirm": "Confirmar",
"cancel": "Cancelar"
},
"welcome": {
"title": "Bienvenido a tu buzón",
"tip_compose": "Pulsa c para redactar un nuevo correo",
"tip_shortcuts": "Pulsa ? para ver todos los atajos de teclado",
"tip_sidebar": "Encuentra Calendario, Contactos y Ajustes en el menú lateral",
"tip_settings": "Personaliza tu experiencia en Ajustes",
"got_it": "Entendido",
"dismiss": "Cerrar"
} }
} }
+88 -16
View File
@@ -12,7 +12,8 @@
"invalid_credentials": "Email ou mot de passe invalide", "invalid_credentials": "Email ou mot de passe invalide",
"connection_failed": "Échec de la connexion au serveur", "connection_failed": "Échec de la connexion au serveur",
"generic": "Une erreur s'est produite. Veuillez réessayer.", "generic": "Une erreur s'est produite. Veuillez réessayer.",
"totp_invalid": "Code d'authentification invalide. Vérifiez votre application d'authentification." "totp_invalid": "Code d'authentification invalide. Vérifiez votre application d'authentification.",
"server_error": "Le serveur est temporairement indisponible. Veuillez réessayer plus tard."
}, },
"config_error": { "config_error": {
"title": "Erreur de configuration", "title": "Erreur de configuration",
@@ -23,12 +24,20 @@
"totp_toggle": "J'ai l'authentification à deux facteurs", "totp_toggle": "J'ai l'authentification à deux facteurs",
"totp_label": "Code d'authentification", "totp_label": "Code d'authentification",
"totp_placeholder": "000000", "totp_placeholder": "000000",
"totp_hide": "Masquer l'authentification à deux facteurs" "totp_hide": "Masquer l'authentification à deux facteurs",
"totp_checkbox": "Utiliser un code d'authentification à deux facteurs",
"session_expired": "Votre session a expiré. Veuillez vous reconnecter.",
"dismiss": "Fermer",
"show_password": "Afficher le mot de passe",
"hide_password": "Masquer le mot de passe",
"totp_hint": "Activez si votre compte nécessite un code d'authentification à deux facteurs"
}, },
"sidebar": { "sidebar": {
"close": "Fermer", "close": "Fermer",
"compose": "Composer", "compose": "Composer",
"compose_hint": "Composer (c)",
"search_placeholder": "Rechercher un email...", "search_placeholder": "Rechercher un email...",
"search_placeholder_hint": "Rechercher... (appuyez sur /)",
"storage": "Stockage", "storage": "Stockage",
"sign_out": "Se déconnecter", "sign_out": "Se déconnecter",
"contacts": "Contacts", "contacts": "Contacts",
@@ -66,7 +75,9 @@
"go_back": "Retour" "go_back": "Retour"
}, },
"clear_search": "Effacer la recherche", "clear_search": "Effacer la recherche",
"vacation_active": "Répondeur d'absence activé" "vacation_active": "Répondeur d'absence activé",
"mail": "Messagerie",
"nav_label": "Navigation"
}, },
"email_list": { "email_list": {
"no_emails": "Aucun email", "no_emails": "Aucun email",
@@ -202,7 +213,12 @@
"tooltips": { "tooltips": {
"reply": "Répondre", "reply": "Répondre",
"archive": "Archiver", "archive": "Archiver",
"delete": "Supprimer" "delete": "Supprimer",
"reply_all": "Répondre à tous (a)",
"forward": "Transférer (f)",
"star": "Suivre (s)",
"unstar": "Ne plus suivre (s)",
"compose": "Rédiger (c)"
}, },
"spam": { "spam": {
"button_title": "Signaler comme spam", "button_title": "Signaler comme spam",
@@ -242,7 +258,8 @@
"no_calendar": "Calendrier non disponible", "no_calendar": "Calendrier non disponible",
"select_calendar": "Choisir un calendrier", "select_calendar": "Choisir un calendrier",
"already_in_calendar": "Déjà dans votre calendrier" "already_in_calendar": "Déjà dans votre calendrier"
} },
"keyboard_shortcuts": "Raccourcis clavier (?)"
}, },
"email_composer": { "email_composer": {
"new_message": "Nouveau message", "new_message": "Nouveau message",
@@ -290,7 +307,17 @@
}, },
"remove_sub_address": "Retirer le sous-adressage", "remove_sub_address": "Retirer le sous-adressage",
"use_template": "Modèle", "use_template": "Modèle",
"save_as_template": "Enregistrer comme modèle" "save_as_template": "Enregistrer comme modèle",
"validation": {
"recipient_required": "Ajoutez un destinataire pour envoyer",
"subject_required": "Ajoutez un objet",
"body_required": "Rédigez un message ou joignez un fichier"
},
"upload_progress": "Envoi {uploaded} / {total}",
"upload_cancel": "Annuler l'envoi",
"upload_failed": "Échec du téléversement de {filename}",
"send_failed": "Échec de l'envoi de l'e-mail",
"discard_draft_title": "Supprimer le brouillon ?"
}, },
"common": { "common": {
"loading": "Chargement...", "loading": "Chargement...",
@@ -788,7 +815,12 @@
"rule_summary": { "rule_summary": {
"conditions_count": "{count, plural, one {# condition} other {# conditions}}", "conditions_count": "{count, plural, one {# condition} other {# conditions}}",
"actions_count": "{count, plural, one {# action} other {# actions}}" "actions_count": "{count, plural, one {# action} other {# actions}}"
} },
"templates_section": "Partir d'un modèle",
"template_newsletters": "Déplacer les newsletters dans un dossier",
"template_receipts": "Archiver les reçus automatiquement",
"template_important": "Marquer les e-mails importants",
"template_notifications": "Filtrer les notifications"
}, },
"templates": { "templates": {
"title": "Modèles d'e-mails", "title": "Modèles d'e-mails",
@@ -822,7 +854,11 @@
"empty": "Le nom du modèle est requis", "empty": "Le nom du modèle est requis",
"too_long": "Le nom du modèle ne doit pas dépasser 200 caractères" "too_long": "Le nom du modèle ne doit pas dépasser 200 caractères"
} }
} },
"unsaved_changes": "Vous avez des modifications non enregistrées",
"discard_changes": "Abandonner les modifications ?",
"discard": "Abandonner",
"keep_editing": "Continuer l'édition"
}, },
"errors": { "errors": {
"page_error_title": "Une erreur s'est produite", "page_error_title": "Une erreur s'est produite",
@@ -910,7 +946,9 @@
"archive": "Archiver la conversation", "archive": "Archiver la conversation",
"delete": "Supprimer la conversation", "delete": "Supprimer la conversation",
"star": "Marquer la conversation comme favorite", "star": "Marquer la conversation comme favorite",
"unstar": "Retirer des favoris" "unstar": "Retirer des favoris",
"messages_tooltip": "{count, plural, one {# message dans cette conversation} other {# messages dans cette conversation}}",
"toggle_thread": "Déplier/replier la discussion"
}, },
"identities": { "identities": {
"modal_title": "Gérer les identités d'envoi", "modal_title": "Gérer les identités d'envoi",
@@ -1019,7 +1057,12 @@
"notes": "Notes", "notes": "Notes",
"no_contact_selected": "Sélectionnez un contact pour voir les détails", "no_contact_selected": "Sélectionnez un contact pour voir les détails",
"created": "Créé", "created": "Créé",
"updated": "Dernière mise à jour" "updated": "Dernière mise à jour",
"compose_email": "Écrire un e-mail",
"copy_email": "Copier l'e-mail",
"copy_phone": "Copier le numéro",
"copied": "Copié dans le presse-papiers",
"copy_failed": "Échec de la copie dans le presse-papiers"
}, },
"form": { "form": {
"create_title": "Nouveau contact", "create_title": "Nouveau contact",
@@ -1044,7 +1087,8 @@
"updating": "Mise à jour...", "updating": "Mise à jour...",
"name_required": "Un prénom ou un nom est requis", "name_required": "Un prénom ou un nom est requis",
"email_invalid": "Veuillez saisir une adresse e-mail valide", "email_invalid": "Veuillez saisir une adresse e-mail valide",
"save_failed": "Échec de l'enregistrement du contact" "save_failed": "Échec de l'enregistrement du contact",
"email_error_inline": "Format d'e-mail invalide"
}, },
"groups": { "groups": {
"create": "Nouveau groupe", "create": "Nouveau groupe",
@@ -1102,7 +1146,12 @@
"error_create": "Échec de la création du contact", "error_create": "Échec de la création du contact",
"error_update": "Échec de la mise à jour du contact", "error_update": "Échec de la mise à jour du contact",
"error_delete": "Échec de la suppression du contact" "error_delete": "Échec de la suppression du contact"
} },
"empty_state_title": "Aucun contact",
"empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard",
"empty_search_hint": "Essayez un autre terme de recherche",
"clear_search": "Effacer la recherche",
"import_vcard": "Importer vCard"
}, },
"calendar": { "calendar": {
"title": "Calendrier", "title": "Calendrier",
@@ -1113,7 +1162,11 @@
"week": "Semaine", "week": "Semaine",
"day": "Jour", "day": "Jour",
"agenda": "Agenda", "agenda": "Agenda",
"today": "Aujourd'hui" "today": "Aujourd'hui",
"month_hint": "Mois (m)",
"week_hint": "Semaine (w)",
"day_hint": "Jour (d)",
"agenda_hint": "Agenda (a)"
}, },
"events": { "events": {
"create": "Créer un événement", "create": "Créer un événement",
@@ -1159,7 +1212,10 @@
"you_organizer": "Vous êtes l'organisateur", "you_organizer": "Vous êtes l'organisateur",
"you_attendee": "Vous êtes un participant", "you_attendee": "Vous êtes un participant",
"no_participants": "Aucun participant", "no_participants": "Aucun participant",
"count": "{count, plural, one {# participant} other {# participants}}" "count": "{count, plural, one {# participant} other {# participants}}",
"invited_by": "Invitation de {name}",
"respond_below": "Répondez avec les boutons ci-dessous",
"rsvp_label": "Votre réponse"
}, },
"recurrence": { "recurrence": {
"title": "Récurrence", "title": "Récurrence",
@@ -1249,7 +1305,8 @@
"error": "Échec de l'importation du calendrier", "error": "Échec de l'importation du calendrier",
"file_too_large": "Le fichier dépasse la limite de 5 Mo", "file_too_large": "Le fichier dépasse la limite de 5 Mo",
"invalid_format": "Format de fichier calendrier invalide" "invalid_format": "Format de fichier calendrier invalide"
} },
"mini_calendar_change": "Cliquer pour changer de mois"
}, },
"advanced_search": { "advanced_search": {
"title": "Recherche avancée", "title": "Recherche avancée",
@@ -1272,6 +1329,21 @@
"clear_all": "Tout effacer", "clear_all": "Tout effacer",
"filters_active": "{count} filtre", "filters_active": "{count} filtre",
"filters_active_plural": "{count} filtres", "filters_active_plural": "{count} filtres",
"toggle_filters": "Filtres" "toggle_filters": "Filtres",
"search_hint": "Utilisez les filtres avancés pour une recherche précise",
"advanced_filters_tooltip": "Filtres de recherche avancés"
},
"welcome": {
"title": "Bienvenue dans votre boite mail",
"tip_compose": "Appuyez sur c pour composer un nouvel email",
"tip_shortcuts": "Appuyez sur ? pour voir tous les raccourcis clavier",
"tip_sidebar": "Retrouvez le Calendrier, les Contacts et les Parametres dans le menu lateral",
"tip_settings": "Personnalisez votre experience dans les Parametres",
"got_it": "Compris",
"dismiss": "Fermer"
},
"confirm_dialog": {
"confirm": "Confirmer",
"cancel": "Annuler"
} }
} }
+88 -16
View File
@@ -12,7 +12,8 @@
"invalid_credentials": "Email o password non valida", "invalid_credentials": "Email o password non valida",
"connection_failed": "Impossibile connettersi al server", "connection_failed": "Impossibile connettersi al server",
"generic": "Si è verificato un errore. Riprova.", "generic": "Si è verificato un errore. Riprova.",
"totp_invalid": "Codice di autenticazione non valido. Controlla la tua app di autenticazione." "totp_invalid": "Codice di autenticazione non valido. Controlla la tua app di autenticazione.",
"server_error": "Il server non è temporaneamente disponibile. Riprova più tardi."
}, },
"config_error": { "config_error": {
"title": "Errore di configurazione", "title": "Errore di configurazione",
@@ -23,7 +24,13 @@
"totp_toggle": "Ho l'autenticazione a due fattori", "totp_toggle": "Ho l'autenticazione a due fattori",
"totp_label": "Codice di autenticazione", "totp_label": "Codice di autenticazione",
"totp_placeholder": "000000", "totp_placeholder": "000000",
"totp_hide": "Nascondi autenticazione a due fattori" "totp_hide": "Nascondi autenticazione a due fattori",
"show_password": "Mostra password",
"hide_password": "Nascondi password",
"totp_hint": "Attiva se il tuo account richiede un codice di autenticazione a due fattori",
"totp_checkbox": "Usa codice di autenticazione a due fattori",
"session_expired": "La sessione è scaduta. Accedi di nuovo.",
"dismiss": "Chiudi"
}, },
"sidebar": { "sidebar": {
"close": "Chiudi", "close": "Chiudi",
@@ -66,7 +73,11 @@
"go_back": "Indietro" "go_back": "Indietro"
}, },
"clear_search": "Cancella ricerca", "clear_search": "Cancella ricerca",
"vacation_active": "Risponditore automatico attivo" "vacation_active": "Risponditore automatico attivo",
"compose_hint": "Scrivi (c)",
"search_placeholder_hint": "Cerca posta... (premi /)",
"mail": "Posta",
"nav_label": "Navigazione"
}, },
"email_list": { "email_list": {
"no_emails": "Nessun messaggio", "no_emails": "Nessun messaggio",
@@ -202,7 +213,12 @@
"tooltips": { "tooltips": {
"reply": "Rispondi", "reply": "Rispondi",
"archive": "Archivia", "archive": "Archivia",
"delete": "Elimina" "delete": "Elimina",
"reply_all": "Rispondi a tutti (a)",
"forward": "Inoltra (f)",
"star": "Aggiungi stella (s)",
"unstar": "Rimuovi stella (s)",
"compose": "Scrivi (c)"
}, },
"spam": { "spam": {
"button_title": "Segnala come spam", "button_title": "Segnala come spam",
@@ -242,7 +258,8 @@
"no_calendar": "Calendario non disponibile", "no_calendar": "Calendario non disponibile",
"select_calendar": "Seleziona calendario", "select_calendar": "Seleziona calendario",
"already_in_calendar": "Già nel tuo calendario" "already_in_calendar": "Già nel tuo calendario"
} },
"keyboard_shortcuts": "Scorciatoie da tastiera (?)"
}, },
"email_composer": { "email_composer": {
"new_message": "Nuovo messaggio", "new_message": "Nuovo messaggio",
@@ -290,7 +307,17 @@
}, },
"remove_sub_address": "Rimuovi sotto-indirizzo", "remove_sub_address": "Rimuovi sotto-indirizzo",
"use_template": "Modello", "use_template": "Modello",
"save_as_template": "Salva come modello" "save_as_template": "Salva come modello",
"validation": {
"recipient_required": "Aggiungi un destinatario per inviare",
"subject_required": "Aggiungi un oggetto",
"body_required": "Scrivi un messaggio o allega un file"
},
"upload_progress": "Caricamento {uploaded} / {total}",
"upload_cancel": "Annulla caricamento",
"upload_failed": "Caricamento di {filename} non riuscito",
"send_failed": "Invio dell'e-mail non riuscito",
"discard_draft_title": "Eliminare la bozza?"
}, },
"common": { "common": {
"loading": "Caricamento...", "loading": "Caricamento...",
@@ -788,7 +815,12 @@
"rule_summary": { "rule_summary": {
"conditions_count": "{count, plural, one {# condizione} other {# condizioni}}", "conditions_count": "{count, plural, one {# condizione} other {# condizioni}}",
"actions_count": "{count, plural, one {# azione} other {# azioni}}" "actions_count": "{count, plural, one {# azione} other {# azioni}}"
} },
"templates_section": "Parti da un modello",
"template_newsletters": "Sposta newsletter in cartella",
"template_receipts": "Archivia ricevute automaticamente",
"template_important": "Contrassegna email importanti",
"template_notifications": "Filtra notifiche"
}, },
"templates": { "templates": {
"title": "Modelli email", "title": "Modelli email",
@@ -822,7 +854,11 @@
"empty": "Il nome del modello è obbligatorio", "empty": "Il nome del modello è obbligatorio",
"too_long": "Il nome del modello non deve superare i 200 caratteri" "too_long": "Il nome del modello non deve superare i 200 caratteri"
} }
} },
"unsaved_changes": "Hai modifiche non salvate",
"discard_changes": "Annullare le modifiche?",
"discard": "Annulla",
"keep_editing": "Continua a modificare"
}, },
"errors": { "errors": {
"page_error_title": "Qualcosa è andato storto", "page_error_title": "Qualcosa è andato storto",
@@ -910,7 +946,9 @@
"archive": "Archivia conversazione", "archive": "Archivia conversazione",
"delete": "Elimina conversazione", "delete": "Elimina conversazione",
"star": "Aggiungi stella alla conversazione", "star": "Aggiungi stella alla conversazione",
"unstar": "Rimuovi stella dalla conversazione" "unstar": "Rimuovi stella dalla conversazione",
"messages_tooltip": "{count, plural, one {# messaggio in questa conversazione} other {# messaggi in questa conversazione}}",
"toggle_thread": "Espandi/comprimi discussione"
}, },
"identities": { "identities": {
"modal_title": "Gestisci identità di invio", "modal_title": "Gestisci identità di invio",
@@ -1019,7 +1057,12 @@
"notes": "Note", "notes": "Note",
"no_contact_selected": "Seleziona un contatto per vedere i dettagli", "no_contact_selected": "Seleziona un contatto per vedere i dettagli",
"created": "Creato", "created": "Creato",
"updated": "Ultimo aggiornamento" "updated": "Ultimo aggiornamento",
"compose_email": "Scrivi email",
"copy_email": "Copia email",
"copy_phone": "Copia telefono",
"copied": "Copiato negli appunti",
"copy_failed": "Impossibile copiare negli appunti"
}, },
"form": { "form": {
"create_title": "Nuovo contatto", "create_title": "Nuovo contatto",
@@ -1044,7 +1087,8 @@
"updating": "Aggiornamento...", "updating": "Aggiornamento...",
"name_required": "È richiesto almeno un nome o cognome", "name_required": "È richiesto almeno un nome o cognome",
"email_invalid": "Inserisci un indirizzo email valido", "email_invalid": "Inserisci un indirizzo email valido",
"save_failed": "Impossibile salvare il contatto" "save_failed": "Impossibile salvare il contatto",
"email_error_inline": "Formato email non valido"
}, },
"groups": { "groups": {
"create": "Nuovo gruppo", "create": "Nuovo gruppo",
@@ -1102,7 +1146,12 @@
"error_create": "Impossibile creare il contatto", "error_create": "Impossibile creare il contatto",
"error_update": "Impossibile aggiornare il contatto", "error_update": "Impossibile aggiornare il contatto",
"error_delete": "Impossibile eliminare il contatto" "error_delete": "Impossibile eliminare il contatto"
} },
"empty_state_title": "Nessun contatto",
"empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard",
"empty_search_hint": "Prova con un altro termine di ricerca",
"clear_search": "Cancella ricerca",
"import_vcard": "Importa vCard"
}, },
"calendar": { "calendar": {
"title": "Calendario", "title": "Calendario",
@@ -1113,7 +1162,11 @@
"week": "Settimana", "week": "Settimana",
"day": "Giorno", "day": "Giorno",
"agenda": "Agenda", "agenda": "Agenda",
"today": "Oggi" "today": "Oggi",
"month_hint": "Mese (m)",
"week_hint": "Settimana (w)",
"day_hint": "Giorno (d)",
"agenda_hint": "Agenda (a)"
}, },
"events": { "events": {
"create": "Crea evento", "create": "Crea evento",
@@ -1159,7 +1212,10 @@
"you_organizer": "Sei l'organizzatore", "you_organizer": "Sei l'organizzatore",
"you_attendee": "Sei un partecipante", "you_attendee": "Sei un partecipante",
"no_participants": "Nessun partecipante", "no_participants": "Nessun partecipante",
"count": "{count, plural, one {# partecipante} other {# partecipanti}}" "count": "{count, plural, one {# partecipante} other {# partecipanti}}",
"invited_by": "Invito da {name}",
"respond_below": "Rispondi con i pulsanti qui sotto",
"rsvp_label": "La tua risposta"
}, },
"recurrence": { "recurrence": {
"title": "Ricorrenza", "title": "Ricorrenza",
@@ -1249,7 +1305,8 @@
"error": "Importazione del calendario fallita", "error": "Importazione del calendario fallita",
"file_too_large": "Il file supera il limite di 5 MB", "file_too_large": "Il file supera il limite di 5 MB",
"invalid_format": "Formato del file calendario non valido" "invalid_format": "Formato del file calendario non valido"
} },
"mini_calendar_change": "Clicca per cambiare mese"
}, },
"advanced_search": { "advanced_search": {
"title": "Ricerca avanzata", "title": "Ricerca avanzata",
@@ -1272,6 +1329,21 @@
"clear_all": "Cancella tutto", "clear_all": "Cancella tutto",
"filters_active": "{count} filtro", "filters_active": "{count} filtro",
"filters_active_plural": "{count} filtri", "filters_active_plural": "{count} filtri",
"toggle_filters": "Filtri" "toggle_filters": "Filtri",
"search_hint": "Usa i filtri avanzati per una ricerca precisa",
"advanced_filters_tooltip": "Filtri di ricerca avanzati"
},
"confirm_dialog": {
"confirm": "Conferma",
"cancel": "Annulla"
},
"welcome": {
"title": "Benvenuto nella tua casella di posta",
"tip_compose": "Premi c per scrivere una nuova email",
"tip_shortcuts": "Premi ? per vedere tutte le scorciatoie da tastiera",
"tip_sidebar": "Trova Calendario, Contatti e Impostazioni nel menu laterale",
"tip_settings": "Personalizza la tua esperienza nelle Impostazioni",
"got_it": "Ho capito",
"dismiss": "Chiudi"
} }
} }
+88 -16
View File
@@ -12,7 +12,8 @@
"invalid_credentials": "メールアドレスまたはパスワードが無効です", "invalid_credentials": "メールアドレスまたはパスワードが無効です",
"connection_failed": "サーバーへの接続に失敗しました", "connection_failed": "サーバーへの接続に失敗しました",
"generic": "エラーが発生しました。もう一度お試しください。", "generic": "エラーが発生しました。もう一度お試しください。",
"totp_invalid": "認証コードが無効です。認証アプリを確認してください。" "totp_invalid": "認証コードが無効です。認証アプリを確認してください。",
"server_error": "サーバーが一時的に利用できません。後でもう一度お試しください。"
}, },
"config_error": { "config_error": {
"title": "設定エラー", "title": "設定エラー",
@@ -23,12 +24,20 @@
"totp_toggle": "二要素認証を使用", "totp_toggle": "二要素認証を使用",
"totp_label": "認証コード", "totp_label": "認証コード",
"totp_placeholder": "000000", "totp_placeholder": "000000",
"totp_hide": "二要素認証を非表示" "totp_hide": "二要素認証を非表示",
"totp_checkbox": "二要素認証コードを使用する",
"session_expired": "セッションが期限切れになりました。再度サインインしてください。",
"dismiss": "閉じる",
"show_password": "パスワードを表示",
"hide_password": "パスワードを隠す",
"totp_hint": "アカウントに二要素認証コードが必要な場合に有効にしてください"
}, },
"sidebar": { "sidebar": {
"close": "閉じる", "close": "閉じる",
"compose": "作成", "compose": "作成",
"compose_hint": "作成 (c)",
"search_placeholder": "メールを検索...", "search_placeholder": "メールを検索...",
"search_placeholder_hint": "メールを検索... (/ を押す)",
"storage": "ストレージ", "storage": "ストレージ",
"sign_out": "サインアウト", "sign_out": "サインアウト",
"contacts": "連絡先", "contacts": "連絡先",
@@ -66,7 +75,9 @@
"go_back": "戻る" "go_back": "戻る"
}, },
"clear_search": "検索をクリア", "clear_search": "検索をクリア",
"vacation_active": "不在応答が有効です" "vacation_active": "不在応答が有効です",
"mail": "メール",
"nav_label": "ナビゲーション"
}, },
"email_list": { "email_list": {
"no_emails": "メールがありません", "no_emails": "メールがありません",
@@ -202,7 +213,12 @@
"tooltips": { "tooltips": {
"reply": "返信", "reply": "返信",
"archive": "アーカイブ", "archive": "アーカイブ",
"delete": "削除" "delete": "削除",
"reply_all": "全員に返信 (a)",
"forward": "転送 (f)",
"star": "スター (s)",
"unstar": "スター解除 (s)",
"compose": "新規作成 (c)"
}, },
"spam": { "spam": {
"button_title": "迷惑メールを報告", "button_title": "迷惑メールを報告",
@@ -242,7 +258,8 @@
"no_calendar": "カレンダーが利用できません", "no_calendar": "カレンダーが利用できません",
"select_calendar": "カレンダーを選択", "select_calendar": "カレンダーを選択",
"already_in_calendar": "カレンダーに登録済み" "already_in_calendar": "カレンダーに登録済み"
} },
"keyboard_shortcuts": "キーボードショートカット (?)"
}, },
"email_composer": { "email_composer": {
"new_message": "新規メッセージ", "new_message": "新規メッセージ",
@@ -290,7 +307,17 @@
}, },
"remove_sub_address": "サブアドレスを削除", "remove_sub_address": "サブアドレスを削除",
"use_template": "テンプレート", "use_template": "テンプレート",
"save_as_template": "テンプレートとして保存" "save_as_template": "テンプレートとして保存",
"validation": {
"recipient_required": "送信するには宛先を追加してください",
"subject_required": "件名を追加してください",
"body_required": "メッセージを入力するかファイルを添付してください"
},
"upload_progress": "アップロード中 {uploaded} / {total}",
"upload_cancel": "アップロードをキャンセル",
"upload_failed": "{filename} のアップロードに失敗しました",
"send_failed": "メールの送信に失敗しました",
"discard_draft_title": "下書きを破棄しますか?"
}, },
"common": { "common": {
"loading": "読み込み中...", "loading": "読み込み中...",
@@ -788,7 +815,12 @@
"rule_summary": { "rule_summary": {
"conditions_count": "{count, plural, other {#個の条件}}", "conditions_count": "{count, plural, other {#個の条件}}",
"actions_count": "{count, plural, other {#個のアクション}}" "actions_count": "{count, plural, other {#個のアクション}}"
} },
"templates_section": "テンプレートから作成",
"template_newsletters": "ニュースレターをフォルダに移動",
"template_receipts": "領収書を自動アーカイブ",
"template_important": "重要なメールにフラグを付ける",
"template_notifications": "通知をフィルター"
}, },
"templates": { "templates": {
"title": "メールテンプレート", "title": "メールテンプレート",
@@ -822,7 +854,11 @@
"empty": "テンプレート名は必須です", "empty": "テンプレート名は必須です",
"too_long": "テンプレート名は200文字以内にしてください" "too_long": "テンプレート名は200文字以内にしてください"
} }
} },
"unsaved_changes": "保存されていない変更があります",
"discard_changes": "変更を破棄しますか?",
"discard": "破棄",
"keep_editing": "編集を続ける"
}, },
"errors": { "errors": {
"page_error_title": "問題が発生しました", "page_error_title": "問題が発生しました",
@@ -910,7 +946,9 @@
"archive": "会話をアーカイブ", "archive": "会話をアーカイブ",
"delete": "会話を削除", "delete": "会話を削除",
"star": "会話にスターを付ける", "star": "会話にスターを付ける",
"unstar": "会話のスターを外す" "unstar": "会話のスターを外す",
"messages_tooltip": "{count, plural, other {この会話の#件のメッセージ}}",
"toggle_thread": "スレッドの展開/折りたたみ"
}, },
"identities": { "identities": {
"modal_title": "送信者情報を管理", "modal_title": "送信者情報を管理",
@@ -1019,7 +1057,12 @@
"notes": "メモ", "notes": "メモ",
"no_contact_selected": "連絡先を選択して詳細を表示", "no_contact_selected": "連絡先を選択して詳細を表示",
"created": "作成日", "created": "作成日",
"updated": "最終更新" "updated": "最終更新",
"compose_email": "メールを作成",
"copy_email": "メールをコピー",
"copy_phone": "電話番号をコピー",
"copied": "クリップボードにコピーしました",
"copy_failed": "クリップボードへのコピーに失敗しました"
}, },
"form": { "form": {
"create_title": "新しい連絡先", "create_title": "新しい連絡先",
@@ -1044,7 +1087,8 @@
"updating": "更新中...", "updating": "更新中...",
"name_required": "名前は必須です", "name_required": "名前は必須です",
"email_invalid": "有効なメールアドレスを入力してください", "email_invalid": "有効なメールアドレスを入力してください",
"save_failed": "連絡先の保存に失敗しました" "save_failed": "連絡先の保存に失敗しました",
"email_error_inline": "メールアドレスの形式が正しくありません"
}, },
"groups": { "groups": {
"create": "新しいグループ", "create": "新しいグループ",
@@ -1102,7 +1146,12 @@
"error_create": "連絡先の作成に失敗しました", "error_create": "連絡先の作成に失敗しました",
"error_update": "連絡先の更新に失敗しました", "error_update": "連絡先の更新に失敗しました",
"error_delete": "連絡先の削除に失敗しました" "error_delete": "連絡先の削除に失敗しました"
} },
"empty_state_title": "連絡先がありません",
"empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください",
"empty_search_hint": "別の検索語をお試しください",
"clear_search": "検索をクリア",
"import_vcard": "vCardをインポート"
}, },
"calendar": { "calendar": {
"title": "カレンダー", "title": "カレンダー",
@@ -1113,7 +1162,11 @@
"week": "週", "week": "週",
"day": "日", "day": "日",
"agenda": "予定リスト", "agenda": "予定リスト",
"today": "今日" "today": "今日",
"month_hint": "月 (m)",
"week_hint": "週 (w)",
"day_hint": "日 (d)",
"agenda_hint": "予定一覧 (a)"
}, },
"events": { "events": {
"create": "予定を作成", "create": "予定を作成",
@@ -1159,7 +1212,10 @@
"you_organizer": "あなたは主催者です", "you_organizer": "あなたは主催者です",
"you_attendee": "あなたは参加者です", "you_attendee": "あなたは参加者です",
"no_participants": "参加者なし", "no_participants": "参加者なし",
"count": "{count}人の参加者" "count": "{count}人の参加者",
"invited_by": "{name}からの招待",
"respond_below": "以下のボタンで返答してください",
"rsvp_label": "あなたの返答"
}, },
"recurrence": { "recurrence": {
"title": "繰り返し", "title": "繰り返し",
@@ -1249,7 +1305,8 @@
"error": "カレンダーのインポートに失敗しました", "error": "カレンダーのインポートに失敗しました",
"file_too_large": "ファイルサイズが5MBを超えています", "file_too_large": "ファイルサイズが5MBを超えています",
"invalid_format": "無効なカレンダーファイル形式" "invalid_format": "無効なカレンダーファイル形式"
} },
"mini_calendar_change": "クリックで月を変更"
}, },
"advanced_search": { "advanced_search": {
"title": "詳細検索", "title": "詳細検索",
@@ -1272,6 +1329,21 @@
"clear_all": "すべてクリア", "clear_all": "すべてクリア",
"filters_active": "{count}件のフィルター", "filters_active": "{count}件のフィルター",
"filters_active_plural": "{count}件のフィルター", "filters_active_plural": "{count}件のフィルター",
"toggle_filters": "フィルター" "toggle_filters": "フィルター",
"search_hint": "詳細フィルターで正確に検索",
"advanced_filters_tooltip": "詳細検索フィルター"
},
"confirm_dialog": {
"confirm": "確認",
"cancel": "キャンセル"
},
"welcome": {
"title": "メールボックスへようこそ",
"tip_compose": "cキーで新しいメールを作成",
"tip_shortcuts": "?キーですべてのショートカットを表示",
"tip_sidebar": "サイドバーからカレンダー、連絡先、設定にアクセス",
"tip_settings": "設定でカスタマイズできます",
"got_it": "了解",
"dismiss": "閉じる"
} }
} }
+88 -16
View File
@@ -12,7 +12,8 @@
"invalid_credentials": "Ongeldig e-mailadres of wachtwoord", "invalid_credentials": "Ongeldig e-mailadres of wachtwoord",
"connection_failed": "Kan geen verbinding maken met de server", "connection_failed": "Kan geen verbinding maken met de server",
"generic": "Er is een fout opgetreden. Probeer het opnieuw.", "generic": "Er is een fout opgetreden. Probeer het opnieuw.",
"totp_invalid": "Ongeldige authenticatiecode. Controleer uw authenticator-app." "totp_invalid": "Ongeldige authenticatiecode. Controleer uw authenticator-app.",
"server_error": "De server is tijdelijk niet beschikbaar. Probeer het later opnieuw."
}, },
"config_error": { "config_error": {
"title": "Configuratiefout", "title": "Configuratiefout",
@@ -23,7 +24,13 @@
"totp_toggle": "Ik heb tweefactorauthenticatie", "totp_toggle": "Ik heb tweefactorauthenticatie",
"totp_label": "Authenticatiecode", "totp_label": "Authenticatiecode",
"totp_placeholder": "000000", "totp_placeholder": "000000",
"totp_hide": "Tweefactorauthenticatie verbergen" "totp_hide": "Tweefactorauthenticatie verbergen",
"show_password": "Wachtwoord tonen",
"hide_password": "Wachtwoord verbergen",
"totp_hint": "Schakel in als uw account een tweefactorauthenticatiecode vereist",
"totp_checkbox": "Tweefactorauthenticatiecode gebruiken",
"session_expired": "Uw sessie is verlopen. Meld u opnieuw aan.",
"dismiss": "Sluiten"
}, },
"sidebar": { "sidebar": {
"close": "Sluiten", "close": "Sluiten",
@@ -66,7 +73,11 @@
"go_back": "Terug" "go_back": "Terug"
}, },
"clear_search": "Zoekopdracht wissen", "clear_search": "Zoekopdracht wissen",
"vacation_active": "Afwezigheidsmelder is actief" "vacation_active": "Afwezigheidsmelder is actief",
"compose_hint": "Opstellen (c)",
"search_placeholder_hint": "E-mail zoeken... (druk /)",
"mail": "E-mail",
"nav_label": "Navigatie"
}, },
"email_list": { "email_list": {
"no_emails": "Geen e-mails", "no_emails": "Geen e-mails",
@@ -202,7 +213,12 @@
"tooltips": { "tooltips": {
"reply": "Beantwoorden", "reply": "Beantwoorden",
"archive": "Archiveren", "archive": "Archiveren",
"delete": "Verwijderen" "delete": "Verwijderen",
"reply_all": "Allen beantwoorden (a)",
"forward": "Doorsturen (f)",
"star": "Ster toevoegen (s)",
"unstar": "Ster verwijderen (s)",
"compose": "Opstellen (c)"
}, },
"spam": { "spam": {
"button_title": "Spam melden", "button_title": "Spam melden",
@@ -242,7 +258,8 @@
"no_calendar": "Agenda niet beschikbaar", "no_calendar": "Agenda niet beschikbaar",
"select_calendar": "Agenda selecteren", "select_calendar": "Agenda selecteren",
"already_in_calendar": "Staat al in je agenda" "already_in_calendar": "Staat al in je agenda"
} },
"keyboard_shortcuts": "Sneltoetsen (?)"
}, },
"email_composer": { "email_composer": {
"new_message": "Nieuw bericht", "new_message": "Nieuw bericht",
@@ -290,7 +307,17 @@
}, },
"remove_sub_address": "Sub-adres verwijderen", "remove_sub_address": "Sub-adres verwijderen",
"use_template": "Sjabloon", "use_template": "Sjabloon",
"save_as_template": "Opslaan als sjabloon" "save_as_template": "Opslaan als sjabloon",
"validation": {
"recipient_required": "Voeg een ontvanger toe om te verzenden",
"subject_required": "Voeg een onderwerp toe",
"body_required": "Schrijf een bericht of voeg een bestand toe"
},
"upload_progress": "Uploaden {uploaded} / {total}",
"upload_cancel": "Upload annuleren",
"upload_failed": "Uploaden van {filename} mislukt",
"send_failed": "E-mail verzenden mislukt",
"discard_draft_title": "Concept verwijderen?"
}, },
"common": { "common": {
"loading": "Laden...", "loading": "Laden...",
@@ -788,7 +815,12 @@
"rule_summary": { "rule_summary": {
"conditions_count": "{count, plural, one {# voorwaarde} other {# voorwaarden}}", "conditions_count": "{count, plural, one {# voorwaarde} other {# voorwaarden}}",
"actions_count": "{count, plural, one {# actie} other {# acties}}" "actions_count": "{count, plural, one {# actie} other {# acties}}"
} },
"templates_section": "Starten vanuit sjabloon",
"template_newsletters": "Nieuwsbrieven naar map verplaatsen",
"template_receipts": "Bonnen automatisch archiveren",
"template_important": "Belangrijke e-mails markeren",
"template_notifications": "Meldingen filteren"
}, },
"templates": { "templates": {
"title": "E-mailsjablonen", "title": "E-mailsjablonen",
@@ -822,7 +854,11 @@
"empty": "Sjabloonnaam is verplicht", "empty": "Sjabloonnaam is verplicht",
"too_long": "Sjabloonnaam mag maximaal 200 tekens zijn" "too_long": "Sjabloonnaam mag maximaal 200 tekens zijn"
} }
} },
"unsaved_changes": "U heeft niet-opgeslagen wijzigingen",
"discard_changes": "Wijzigingen verwerpen?",
"discard": "Verwerpen",
"keep_editing": "Doorgaan met bewerken"
}, },
"errors": { "errors": {
"page_error_title": "Er is iets misgegaan", "page_error_title": "Er is iets misgegaan",
@@ -910,7 +946,9 @@
"archive": "Gesprek archiveren", "archive": "Gesprek archiveren",
"delete": "Gesprek verwijderen", "delete": "Gesprek verwijderen",
"star": "Ster toevoegen aan gesprek", "star": "Ster toevoegen aan gesprek",
"unstar": "Ster verwijderen van gesprek" "unstar": "Ster verwijderen van gesprek",
"messages_tooltip": "{count, plural, one {# bericht in dit gesprek} other {# berichten in dit gesprek}}",
"toggle_thread": "Gesprek in-/uitvouwen"
}, },
"identities": { "identities": {
"modal_title": "Verzendidentiteiten beheren", "modal_title": "Verzendidentiteiten beheren",
@@ -1019,7 +1057,12 @@
"notes": "Notities", "notes": "Notities",
"no_contact_selected": "Selecteer een contact om details te bekijken", "no_contact_selected": "Selecteer een contact om details te bekijken",
"created": "Aangemaakt", "created": "Aangemaakt",
"updated": "Laatst bijgewerkt" "updated": "Laatst bijgewerkt",
"compose_email": "E-mail schrijven",
"copy_email": "E-mail kopiëren",
"copy_phone": "Telefoonnummer kopiëren",
"copied": "Gekopieerd naar klembord",
"copy_failed": "Kopiëren naar klembord mislukt"
}, },
"form": { "form": {
"create_title": "Nieuw contact", "create_title": "Nieuw contact",
@@ -1044,7 +1087,8 @@
"updating": "Bijwerken...", "updating": "Bijwerken...",
"name_required": "Ten minste een voor- of achternaam is vereist", "name_required": "Ten minste een voor- of achternaam is vereist",
"email_invalid": "Voer een geldig e-mailadres in", "email_invalid": "Voer een geldig e-mailadres in",
"save_failed": "Kon contact niet opslaan" "save_failed": "Kon contact niet opslaan",
"email_error_inline": "Ongeldig e-mailformaat"
}, },
"groups": { "groups": {
"create": "Nieuwe groep", "create": "Nieuwe groep",
@@ -1102,7 +1146,12 @@
"error_create": "Kon contact niet aanmaken", "error_create": "Kon contact niet aanmaken",
"error_update": "Kon contact niet bijwerken", "error_update": "Kon contact niet bijwerken",
"error_delete": "Kon contact niet verwijderen" "error_delete": "Kon contact niet verwijderen"
} },
"empty_state_title": "Geen contacten",
"empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand",
"empty_search_hint": "Probeer een andere zoekterm",
"clear_search": "Zoekopdracht wissen",
"import_vcard": "vCard importeren"
}, },
"calendar": { "calendar": {
"title": "Agenda", "title": "Agenda",
@@ -1113,7 +1162,11 @@
"week": "Week", "week": "Week",
"day": "Dag", "day": "Dag",
"agenda": "Agenda", "agenda": "Agenda",
"today": "Vandaag" "today": "Vandaag",
"month_hint": "Maand (m)",
"week_hint": "Week (w)",
"day_hint": "Dag (d)",
"agenda_hint": "Agenda (a)"
}, },
"events": { "events": {
"create": "Evenement aanmaken", "create": "Evenement aanmaken",
@@ -1159,7 +1212,10 @@
"you_organizer": "Je bent de organisator", "you_organizer": "Je bent de organisator",
"you_attendee": "Je bent een deelnemer", "you_attendee": "Je bent een deelnemer",
"no_participants": "Geen deelnemers", "no_participants": "Geen deelnemers",
"count": "{count, plural, one {# deelnemer} other {# deelnemers}}" "count": "{count, plural, one {# deelnemer} other {# deelnemers}}",
"invited_by": "Uitnodiging van {name}",
"respond_below": "Reageer met de knoppen hieronder",
"rsvp_label": "Uw reactie"
}, },
"recurrence": { "recurrence": {
"title": "Herhaling", "title": "Herhaling",
@@ -1249,7 +1305,8 @@
"error": "Agenda importeren mislukt", "error": "Agenda importeren mislukt",
"file_too_large": "Bestand overschrijdt de limiet van 5 MB", "file_too_large": "Bestand overschrijdt de limiet van 5 MB",
"invalid_format": "Ongeldig agendabestandsformaat" "invalid_format": "Ongeldig agendabestandsformaat"
} },
"mini_calendar_change": "Klik om van maand te wisselen"
}, },
"advanced_search": { "advanced_search": {
"title": "Geavanceerd zoeken", "title": "Geavanceerd zoeken",
@@ -1272,6 +1329,21 @@
"clear_all": "Alles wissen", "clear_all": "Alles wissen",
"filters_active": "{count} filter", "filters_active": "{count} filter",
"filters_active_plural": "{count} filters", "filters_active_plural": "{count} filters",
"toggle_filters": "Filters" "toggle_filters": "Filters",
"search_hint": "Gebruik geavanceerde filters voor een nauwkeurige zoekopdracht",
"advanced_filters_tooltip": "Geavanceerde zoekfilters"
},
"confirm_dialog": {
"confirm": "Bevestigen",
"cancel": "Annuleren"
},
"welcome": {
"title": "Welkom bij uw mailbox",
"tip_compose": "Druk op c om een nieuwe e-mail te schrijven",
"tip_shortcuts": "Druk op ? om alle sneltoetsen te bekijken",
"tip_sidebar": "Vind Agenda, Contacten en Instellingen in het zijmenu",
"tip_settings": "Pas uw ervaring aan in Instellingen",
"got_it": "Begrepen",
"dismiss": "Sluiten"
} }
} }
+88 -16
View File
@@ -12,7 +12,8 @@
"invalid_credentials": "E-mail ou senha inválidos", "invalid_credentials": "E-mail ou senha inválidos",
"connection_failed": "Falha ao conectar com o servidor", "connection_failed": "Falha ao conectar com o servidor",
"generic": "Ocorreu um erro. Por favor, tente novamente.", "generic": "Ocorreu um erro. Por favor, tente novamente.",
"totp_invalid": "Código de autenticação inválido. Verifique seu aplicativo de autenticação." "totp_invalid": "Código de autenticação inválido. Verifique seu aplicativo de autenticação.",
"server_error": "O servidor está temporariamente indisponível. Tente novamente mais tarde."
}, },
"config_error": { "config_error": {
"title": "Erro de Configuração", "title": "Erro de Configuração",
@@ -23,7 +24,13 @@
"totp_toggle": "Tenho autenticação de dois fatores", "totp_toggle": "Tenho autenticação de dois fatores",
"totp_label": "Código de autenticação", "totp_label": "Código de autenticação",
"totp_placeholder": "000000", "totp_placeholder": "000000",
"totp_hide": "Ocultar autenticação de dois fatores" "totp_hide": "Ocultar autenticação de dois fatores",
"show_password": "Mostrar senha",
"hide_password": "Ocultar senha",
"totp_hint": "Ative se sua conta requer um código de autenticação de dois fatores",
"totp_checkbox": "Usar código de autenticação de dois fatores",
"session_expired": "Sua sessão expirou. Faça login novamente.",
"dismiss": "Fechar"
}, },
"sidebar": { "sidebar": {
"close": "Fechar", "close": "Fechar",
@@ -66,7 +73,11 @@
"go_back": "Voltar" "go_back": "Voltar"
}, },
"clear_search": "Limpar busca", "clear_search": "Limpar busca",
"vacation_active": "Resposta automática ativa" "vacation_active": "Resposta automática ativa",
"compose_hint": "Compor (c)",
"search_placeholder_hint": "Pesquisar e-mail... (pressione /)",
"mail": "E-mail",
"nav_label": "Navegação"
}, },
"email_list": { "email_list": {
"no_emails": "Nenhum e-mail", "no_emails": "Nenhum e-mail",
@@ -202,7 +213,12 @@
"tooltips": { "tooltips": {
"reply": "Responder", "reply": "Responder",
"archive": "Arquivar", "archive": "Arquivar",
"delete": "Excluir" "delete": "Excluir",
"reply_all": "Responder a todos (a)",
"forward": "Encaminhar (f)",
"star": "Favoritar (s)",
"unstar": "Remover favorito (s)",
"compose": "Compor (c)"
}, },
"spam": { "spam": {
"button_title": "Reportar spam", "button_title": "Reportar spam",
@@ -242,7 +258,8 @@
"no_calendar": "Calendário não disponível", "no_calendar": "Calendário não disponível",
"select_calendar": "Selecionar calendário", "select_calendar": "Selecionar calendário",
"already_in_calendar": "Já está no seu calendário" "already_in_calendar": "Já está no seu calendário"
} },
"keyboard_shortcuts": "Atalhos de teclado (?)"
}, },
"email_composer": { "email_composer": {
"new_message": "Nova Mensagem", "new_message": "Nova Mensagem",
@@ -290,7 +307,17 @@
}, },
"remove_sub_address": "Remover sub-endereço", "remove_sub_address": "Remover sub-endereço",
"use_template": "Modelo", "use_template": "Modelo",
"save_as_template": "Salvar como modelo" "save_as_template": "Salvar como modelo",
"validation": {
"recipient_required": "Adicione um destinatário para enviar",
"subject_required": "Adicione um assunto",
"body_required": "Escreva uma mensagem ou anexe um arquivo"
},
"upload_progress": "Enviando {uploaded} / {total}",
"upload_cancel": "Cancelar envio",
"upload_failed": "Falha ao enviar {filename}",
"send_failed": "Falha ao enviar o e-mail",
"discard_draft_title": "Descartar rascunho?"
}, },
"common": { "common": {
"loading": "Carregando...", "loading": "Carregando...",
@@ -788,7 +815,12 @@
"rule_summary": { "rule_summary": {
"conditions_count": "{count, plural, one {# condição} other {# condições}}", "conditions_count": "{count, plural, one {# condição} other {# condições}}",
"actions_count": "{count, plural, one {# ação} other {# ações}}" "actions_count": "{count, plural, one {# ação} other {# ações}}"
} },
"templates_section": "Começar com modelo",
"template_newsletters": "Mover newsletters para pasta",
"template_receipts": "Arquivar recibos automaticamente",
"template_important": "Marcar e-mails importantes",
"template_notifications": "Filtrar notificações"
}, },
"templates": { "templates": {
"title": "Modelos de e-mail", "title": "Modelos de e-mail",
@@ -822,7 +854,11 @@
"empty": "O nome do modelo é obrigatório", "empty": "O nome do modelo é obrigatório",
"too_long": "O nome do modelo não pode exceder 200 caracteres" "too_long": "O nome do modelo não pode exceder 200 caracteres"
} }
} },
"unsaved_changes": "Você tem alterações não salvas",
"discard_changes": "Descartar alterações?",
"discard": "Descartar",
"keep_editing": "Continuar editando"
}, },
"errors": { "errors": {
"page_error_title": "Algo deu errado", "page_error_title": "Algo deu errado",
@@ -910,7 +946,9 @@
"archive": "Arquivar conversa", "archive": "Arquivar conversa",
"delete": "Excluir conversa", "delete": "Excluir conversa",
"star": "Adicionar estrela à conversa", "star": "Adicionar estrela à conversa",
"unstar": "Remover estrela da conversa" "unstar": "Remover estrela da conversa",
"messages_tooltip": "{count, plural, one {# mensagem nesta conversa} other {# mensagens nesta conversa}}",
"toggle_thread": "Expandir/recolher conversa"
}, },
"identities": { "identities": {
"modal_title": "Gerenciar Identidades de Envio", "modal_title": "Gerenciar Identidades de Envio",
@@ -1019,7 +1057,12 @@
"notes": "Notas", "notes": "Notas",
"no_contact_selected": "Selecione um contato para ver os detalhes", "no_contact_selected": "Selecione um contato para ver os detalhes",
"created": "Criado", "created": "Criado",
"updated": "Última atualização" "updated": "Última atualização",
"compose_email": "Escrever e-mail",
"copy_email": "Copiar e-mail",
"copy_phone": "Copiar telefone",
"copied": "Copiado para a área de transferência",
"copy_failed": "Falha ao copiar para a área de transferência"
}, },
"form": { "form": {
"create_title": "Novo contato", "create_title": "Novo contato",
@@ -1044,7 +1087,8 @@
"updating": "Atualizando...", "updating": "Atualizando...",
"name_required": "É necessário pelo menos um nome ou sobrenome", "name_required": "É necessário pelo menos um nome ou sobrenome",
"email_invalid": "Por favor, insira um endereço de e-mail válido", "email_invalid": "Por favor, insira um endereço de e-mail válido",
"save_failed": "Falha ao salvar contato" "save_failed": "Falha ao salvar contato",
"email_error_inline": "Formato de e-mail inválido"
}, },
"groups": { "groups": {
"create": "Novo grupo", "create": "Novo grupo",
@@ -1102,7 +1146,12 @@
"error_create": "Falha ao criar contato", "error_create": "Falha ao criar contato",
"error_update": "Falha ao atualizar contato", "error_update": "Falha ao atualizar contato",
"error_delete": "Falha ao excluir contato" "error_delete": "Falha ao excluir contato"
} },
"empty_state_title": "Sem contatos",
"empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard",
"empty_search_hint": "Tente outro termo de pesquisa",
"clear_search": "Limpar pesquisa",
"import_vcard": "Importar vCard"
}, },
"calendar": { "calendar": {
"title": "Calendário", "title": "Calendário",
@@ -1113,7 +1162,11 @@
"week": "Semana", "week": "Semana",
"day": "Dia", "day": "Dia",
"agenda": "Agenda", "agenda": "Agenda",
"today": "Hoje" "today": "Hoje",
"month_hint": "Mês (m)",
"week_hint": "Semana (w)",
"day_hint": "Dia (d)",
"agenda_hint": "Agenda (a)"
}, },
"events": { "events": {
"create": "Criar evento", "create": "Criar evento",
@@ -1159,7 +1212,10 @@
"you_organizer": "Você é o organizador", "you_organizer": "Você é o organizador",
"you_attendee": "Você é um participante", "you_attendee": "Você é um participante",
"no_participants": "Sem participantes", "no_participants": "Sem participantes",
"count": "{count, plural, one {# participante} other {# participantes}}" "count": "{count, plural, one {# participante} other {# participantes}}",
"invited_by": "Convite de {name}",
"respond_below": "Responda com os botões abaixo",
"rsvp_label": "Sua resposta"
}, },
"recurrence": { "recurrence": {
"title": "Recorrência", "title": "Recorrência",
@@ -1249,7 +1305,8 @@
"error": "Falha ao importar calendário", "error": "Falha ao importar calendário",
"file_too_large": "Arquivo excede o limite de 5 MB", "file_too_large": "Arquivo excede o limite de 5 MB",
"invalid_format": "Formato de arquivo de calendário inválido" "invalid_format": "Formato de arquivo de calendário inválido"
} },
"mini_calendar_change": "Clique para mudar o mês"
}, },
"advanced_search": { "advanced_search": {
"title": "Pesquisa avançada", "title": "Pesquisa avançada",
@@ -1272,6 +1329,21 @@
"clear_all": "Limpar tudo", "clear_all": "Limpar tudo",
"filters_active": "{count} filtro", "filters_active": "{count} filtro",
"filters_active_plural": "{count} filtros", "filters_active_plural": "{count} filtros",
"toggle_filters": "Filtros" "toggle_filters": "Filtros",
"search_hint": "Use os filtros avançados para uma pesquisa precisa",
"advanced_filters_tooltip": "Filtros de pesquisa avançados"
},
"confirm_dialog": {
"confirm": "Confirmar",
"cancel": "Cancelar"
},
"welcome": {
"title": "Bem-vindo à sua caixa de entrada",
"tip_compose": "Pressione c para escrever um novo e-mail",
"tip_shortcuts": "Pressione ? para ver todos os atalhos de teclado",
"tip_sidebar": "Encontre Calendário, Contatos e Configurações no menu lateral",
"tip_settings": "Personalize sua experiência nas Configurações",
"got_it": "Entendi",
"dismiss": "Fechar"
} }
} }
+14 -27
View File
@@ -43,17 +43,11 @@ export const useAuthStore = create<AuthState>()(
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
// Create JMAP client
const client = new JMAPClient(serverUrl, username, effectivePassword); const client = new JMAPClient(serverUrl, username, effectivePassword);
// Try to connect
await client.connect(); await client.connect();
// Fetch identities from the server
const identities = await client.getIdentities(); const identities = await client.getIdentities();
const primaryIdentity = identities.length > 0 ? identities[0] : null; const primaryIdentity = identities.length > 0 ? identities[0] : null;
// Sync identities to identity store
useIdentityStore.getState().setIdentities(identities); useIdentityStore.getState().setIdentities(identities);
// Fetch contacts if server supports JMAP Contacts // Fetch contacts if server supports JMAP Contacts
@@ -66,7 +60,6 @@ export const useAuthStore = create<AuthState>()(
useContactStore.getState().setSupportsSync(false); useContactStore.getState().setSupportsSync(false);
} }
// Initialize vacation responder if supported
const vacationStore = useVacationStore.getState(); const vacationStore = useVacationStore.getState();
if (client.supportsVacationResponse()) { if (client.supportsVacationResponse()) {
vacationStore.setSupported(true); vacationStore.setSupported(true);
@@ -75,14 +68,12 @@ export const useAuthStore = create<AuthState>()(
vacationStore.setSupported(false); vacationStore.setSupported(false);
} }
// Initialize calendar if supported
if (client.supportsCalendars()) { if (client.supportsCalendars()) {
const calendarStore = useCalendarStore.getState(); const calendarStore = useCalendarStore.getState();
calendarStore.setSupported(true); calendarStore.setSupported(true);
calendarStore.fetchCalendars(client).catch((err) => console.error('Failed to fetch calendars:', err)); calendarStore.fetchCalendars(client).catch((err) => console.error('Failed to fetch calendars:', err));
} }
// Initialize Sieve filters if supported
if (client.supportsSieve()) { if (client.supportsSieve()) {
const filterStore = useFilterStore.getState(); const filterStore = useFilterStore.getState();
filterStore.setSupported(true); filterStore.setSupported(true);
@@ -106,15 +97,23 @@ export const useAuthStore = create<AuthState>()(
debug.error('Login error:', error); debug.error('Login error:', error);
let errorKey = 'generic'; let errorKey = 'generic';
// Map common errors to translation keys
if (error instanceof Error) { if (error instanceof Error) {
if (error.message.includes('Invalid username or password') || if (error.message.includes('Invalid username or password') ||
error.message.includes('401') || error.message.includes('401') ||
error.message.includes('Unauthorized')) { error.message.includes('Unauthorized')) {
errorKey = 'invalid_credentials'; errorKey = 'invalid_credentials';
} else if (error.message.includes('network') || } 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'; 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: () => { logout: () => {
const state = get(); const state = get();
// Disconnect the JMAP client if it exists
if (state.client) { if (state.client) {
state.client.disconnect(); state.client.disconnect();
} }
@@ -146,10 +144,8 @@ export const useAuthStore = create<AuthState>()(
error: null, error: null,
}); });
// Clear persisted storage
localStorage.removeItem('auth-storage'); localStorage.removeItem('auth-storage');
// Clear email store state
useEmailStore.setState({ useEmailStore.setState({
emails: [], emails: [],
mailboxes: [], mailboxes: [],
@@ -161,29 +157,21 @@ export const useAuthStore = create<AuthState>()(
quota: null, quota: null,
}); });
// Clear identity store state
useIdentityStore.getState().clearIdentities(); useIdentityStore.getState().clearIdentities();
// Clear contact store state
useContactStore.getState().clearContacts(); useContactStore.getState().clearContacts();
// Clear vacation store state
useVacationStore.getState().clearState(); useVacationStore.getState().clearState();
// Clear calendar store state
useCalendarStore.getState().clearState(); useCalendarStore.getState().clearState();
// Clear filter store state
useFilterStore.getState().clearState(); useFilterStore.getState().clearState();
}, },
checkAuth: async () => { checkAuth: async () => {
const state = get(); 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) { 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({ set({
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
@@ -193,7 +181,6 @@ export const useAuthStore = create<AuthState>()(
}); });
} }
// Mark loading as complete
set({ isLoading: false }); set({ isLoading: false });
}, },
+23 -15
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import { Toast } from "@/components/ui/toast"; import { Toast, ToastAction } from "@/components/ui/toast";
interface ToastStore { interface ToastStore {
toasts: Toast[]; toasts: Toast[];
@@ -16,7 +16,7 @@ export const useToastStore = create<ToastStore>((set) => ({
const newToast: Toast = { const newToast: Toast = {
...toast, ...toast,
id, id,
duration: toast.duration ?? 5000, // Default 5 seconds duration: toast.duration ?? 5000,
}; };
set((state) => ({ 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 = { export const toast = {
success: (title: string, message?: string) => { success: (title: string, options?: string | ToastOptions) => showToast("success", title, options),
useToastStore.getState().addToast({ type: "success", title, message }); error: (title: string, options?: string | ToastOptions) => showToast("error", title, options, 10000),
}, info: (title: string, options?: string | ToastOptions) => showToast("info", title, options),
error: (title: string, message?: string) => { warning: (title: string, options?: string | ToastOptions) => showToast("warning", title, options),
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 });
},
}; };