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
- Dark and light theme support
- Responsive design for mobile and desktop
- Navigation rail (desktop icon sidebar + mobile bottom tab bar)
- Keyboard shortcuts for power users
- Drag-and-drop email organization
- Right-click context menus
- Smooth animations and transitions
- Smooth animations and transitions (respects prefers-reduced-motion)
- 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
- 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] Error boundaries
- [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
- [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] CSP Report-Only headers with per-request nonce
- [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
- [x] Multiple sender identities (name, email, signature)
+46 -32
View File
@@ -23,6 +23,7 @@ import { MiniCalendar } from "@/components/calendar/mini-calendar";
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
import { EventModal } from "@/components/calendar/event-modal";
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { NavigationRail } from "@/components/layout/navigation-rail";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
export default function CalendarPage() {
@@ -308,40 +309,53 @@ export default function CalendarPage() {
};
return (
<div className="flex flex-col h-screen bg-background">
<CalendarToolbar
selectedDate={selectedDate}
viewMode={viewMode}
onNavigateBack={() => router.push("/")}
onPrev={navigatePrev}
onNext={navigateNext}
onToday={goToToday}
onViewModeChange={setViewMode}
onCreateEvent={() => openCreateModal()}
onImport={() => setShowImportModal(true)}
isMobile={isMobile}
/>
<div className="flex h-screen bg-background">
{/* Left Navigation Rail */}
{!isMobile && (
<div className="w-14 border-r border-border bg-secondary flex flex-col items-center py-3 flex-shrink-0">
<NavigationRail collapsed className="py-0" />
</div>
)}
<div className="flex flex-1 overflow-hidden">
{!isMobile && (
<div className="w-60 border-r border-border p-3 overflow-y-auto flex-shrink-0">
<MiniCalendar
selectedDate={selectedDate}
displayMonth={miniMonth}
onSelectDate={handleSelectDate}
onChangeMonth={handleMiniMonthChange}
events={events}
firstDayOfWeek={firstDayOfWeek}
/>
<CalendarSidebarPanel
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
/>
</div>
<div className="flex flex-col flex-1 min-w-0">
<CalendarToolbar
selectedDate={selectedDate}
viewMode={viewMode}
onPrev={navigatePrev}
onNext={navigateNext}
onToday={goToToday}
onViewModeChange={setViewMode}
onCreateEvent={() => openCreateModal()}
onImport={() => setShowImportModal(true)}
isMobile={isMobile}
/>
<div className="flex flex-1 overflow-hidden">
{!isMobile && (
<div className="w-60 border-r border-border p-3 overflow-y-auto flex-shrink-0">
<MiniCalendar
selectedDate={selectedDate}
displayMonth={miniMonth}
onSelectDate={handleSelectDate}
onChangeMonth={handleMiniMonthChange}
events={events}
firstDayOfWeek={firstDayOfWeek}
/>
<CalendarSidebarPanel
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
/>
</div>
)}
{renderView()}
</div>
{/* Mobile Bottom Navigation */}
{isMobile && (
<NavigationRail orientation="horizontal" />
)}
{renderView()}
</div>
{showEventModal && (
+50 -6
View File
@@ -5,6 +5,8 @@ import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { ArrowLeft, Upload, Download, Users, BookUser } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { ContactList } from "@/components/contacts/contact-list";
import { ContactDetail } from "@/components/contacts/contact-detail";
import { ContactForm } from "@/components/contacts/contact-form";
@@ -17,6 +19,8 @@ import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { useIsMobile } from "@/hooks/use-media-query";
import type { ContactCard } from "@/lib/jmap/types";
type View =
@@ -68,6 +72,8 @@ export default function ContactsPage() {
const [view, setView] = useState<View>("list");
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const isMobile = useIsMobile();
useEffect(() => {
if (!isAuthenticated) {
@@ -105,7 +111,14 @@ export default function ContactsPage() {
const handleDelete = async () => {
if (!selectedContact) return;
if (!window.confirm(t("delete_confirm"))) return;
const confirmed = await confirmDialog({
title: t("delete_confirm_title"),
message: t("delete_confirm"),
confirmText: t("form.delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
if (supportsSync && client) {
@@ -178,7 +191,14 @@ export default function ContactsPage() {
const handleDeleteGroup = async () => {
if (!selectedGroup) return;
if (!window.confirm(t("groups.delete_confirm"))) return;
const confirmed = await confirmDialog({
title: t("groups.delete_confirm_title"),
message: t("groups.delete_confirm"),
confirmText: t("form.delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
await deleteGroup(supportsSync && client ? client : null, selectedGroup.id);
@@ -228,7 +248,14 @@ export default function ContactsPage() {
const handleBulkDelete = async () => {
if (selectedContactIds.size === 0) return;
if (!window.confirm(t("bulk.delete_confirm", { count: selectedContactIds.size }))) return;
const confirmed = await confirmDialog({
title: t("bulk.delete_confirm_title"),
message: t("bulk.delete_confirm", { count: selectedContactIds.size }),
confirmText: t("bulk.delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
await bulkDeleteContacts(
@@ -402,7 +429,15 @@ export default function ContactsPage() {
return (
<div className="flex h-screen bg-background">
<div className="w-80 border-r border-border flex flex-col">
{!isMobile && (
<div className="w-14 border-r border-border bg-secondary flex flex-col items-center flex-shrink-0">
<NavigationRail collapsed />
</div>
)}
<div className="flex flex-col flex-1 min-w-0">
<div className="flex flex-1 min-h-0">
<div className="w-80 border-r border-border flex flex-col flex-shrink-0">
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between">
<Button
@@ -482,6 +517,7 @@ export default function ContactsPage() {
onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew}
onImport={() => setView("import")}
supportsSync={supportsSync}
className="flex-1"
selectedContactIds={selectedContactIds}
@@ -504,9 +540,17 @@ export default function ContactsPage() {
)}
</div>
<div className="flex-1">
{renderRightPanel()}
<div className="flex-1 min-w-0">
{renderRightPanel()}
</div>
</div>
{isMobile && (
<NavigationRail orientation="horizontal" />
)}
</div>
<ConfirmDialog {...confirmDialogProps} />
</div>
);
}
+147 -58
View File
@@ -7,7 +7,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth-store";
import { useConfig } from "@/hooks/use-config";
import { Mail, AlertCircle, Loader2, X, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import { Mail, AlertCircle, Loader2, X, ShieldCheck, Info, Eye, EyeOff } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
@@ -15,13 +16,15 @@ export default function LoginPage() {
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { appName, jmapServerUrl: serverUrl, isLoading: configLoading, error: configError } = useConfig();
// All hooks must be called unconditionally at the top
const [formData, setFormData] = useState({
username: "",
password: "",
});
const [showTotpField, setShowTotpField] = useState(false);
const [totpCode, setTotpCode] = useState("");
const [sessionExpired, setSessionExpired] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [shakeError, setShakeError] = useState(false);
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
@@ -30,15 +33,33 @@ export default function LoginPage() {
const suggestionsRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const justSelectedSuggestion = useRef(false);
const totpInputRef = useRef<HTMLInputElement>(null);
const prevError = useRef<string | null>(null);
// Set page title
useEffect(() => {
if (serverUrl) {
document.title = appName;
}
}, [appName, serverUrl]);
// Load saved usernames from localStorage on mount
useEffect(() => {
try {
if (sessionStorage.getItem('session_expired') === 'true') {
setSessionExpired(true);
sessionStorage.removeItem('session_expired');
}
} catch { /* sessionStorage unavailable */ }
}, []);
useEffect(() => {
if (error && error !== prevError.current) {
setShakeError(true);
const timer = setTimeout(() => setShakeError(false), 400);
return () => clearTimeout(timer);
}
prevError.current = error;
}, [error]);
useEffect(() => {
if (!serverUrl) return;
const saved = localStorage.getItem("webmail_usernames");
@@ -62,10 +83,8 @@ export default function LoginPage() {
clearError();
}, [formData, clearError]);
// Filter suggestions based on input
useEffect(() => {
if (!serverUrl) return;
// Skip showing suggestions if we just selected one
if (justSelectedSuggestion.current) {
justSelectedSuggestion.current = false;
return;
@@ -79,14 +98,13 @@ export default function LoginPage() {
setShowSuggestions(filtered.length > 0);
} else if (formData.username === "" && savedUsernames.length > 0) {
setFilteredSuggestions(savedUsernames);
setShowSuggestions(false); // Don't show on empty input
setShowSuggestions(false);
} else {
setShowSuggestions(false);
}
setSelectedSuggestionIndex(-1);
}, [formData.username, savedUsernames, serverUrl]);
// Close suggestions when clicking outside
useEffect(() => {
if (!serverUrl) return;
const handleClickOutside = (event: MouseEvent) => {
@@ -100,7 +118,12 @@ export default function LoginPage() {
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [serverUrl]);
// Show loading state while config is being fetched
useEffect(() => {
if (showTotpField && totpInputRef.current) {
totpInputRef.current.focus();
}
}, [showTotpField]);
if (configLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
@@ -112,7 +135,6 @@ export default function LoginPage() {
);
}
// Show error if config fetch failed
if (configError) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
@@ -129,7 +151,6 @@ export default function LoginPage() {
);
}
// Show error if JMAP server URL is not configured
if (!serverUrl) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
@@ -146,7 +167,6 @@ export default function LoginPage() {
);
}
// Save username on successful login
const saveUsername = (username: string) => {
const saved = localStorage.getItem("webmail_usernames");
let usernames: string[] = [];
@@ -159,7 +179,6 @@ export default function LoginPage() {
}
}
// Add username if not already present, keep max 5 recent usernames
if (!usernames.includes(username)) {
usernames = [username, ...usernames].slice(0, 5);
localStorage.setItem("webmail_usernames", JSON.stringify(usernames));
@@ -167,7 +186,6 @@ export default function LoginPage() {
}
};
// Remove a username from saved list
const removeUsername = (username: string, e: React.MouseEvent) => {
e.stopPropagation();
const updated = savedUsernames.filter(u => u !== username);
@@ -195,7 +213,6 @@ export default function LoginPage() {
justSelectedSuggestion.current = true;
setFormData({ ...formData, username });
setShowSuggestions(false);
// Focus password field
document.getElementById("password")?.focus();
};
@@ -248,6 +265,28 @@ export default function LoginPage() {
</h1>
</div>
{/* Session Expired Banner */}
{sessionExpired && (
<div
className="mb-6 p-4 bg-blue-500/10 border border-blue-500/20 rounded-lg flex items-start gap-3"
role="status"
aria-live="polite"
>
<Info className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-blue-700 dark:text-blue-300 flex-1">
{t("session_expired")}
</p>
<button
type="button"
onClick={() => setSessionExpired(false)}
className="p-0.5 rounded hover:bg-blue-500/10 transition-colors flex-shrink-0"
aria-label={t("dismiss")}
>
<X className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</button>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
@@ -261,8 +300,11 @@ export default function LoginPage() {
)}
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-4">
<form
onSubmit={handleSubmit}
className={cn("space-y-4", shakeError && "animate-shake")}
>
<fieldset disabled={isLoading} className="space-y-4">
<div className="relative">
<Input
ref={inputRef}
@@ -290,9 +332,10 @@ export default function LoginPage() {
{filteredSuggestions.map((username, index) => (
<div
key={username}
className={`px-4 py-2.5 flex items-center justify-between hover:bg-muted cursor-pointer transition-colors ${
index === selectedSuggestionIndex ? "bg-muted" : ""
}`}
className={cn(
"px-4 py-2.5 flex items-center justify-between hover:bg-muted cursor-pointer transition-colors",
index === selectedSuggestionIndex && "bg-muted"
)}
onClick={() => selectSuggestion(username)}
>
<span className="text-sm text-foreground">{username}</span>
@@ -310,45 +353,91 @@ export default function LoginPage() {
)}
</div>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors"
placeholder={t("password_placeholder")}
required
autoComplete="current-password"
/>
{/* TOTP Toggle */}
<button
type="button"
onClick={() => {
setShowTotpField(!showTotpField);
if (showTotpField) setTotpCode("");
}}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ShieldCheck className="w-4 h-4" />
{showTotpField ? t("totp_hide") : t("totp_toggle")}
</button>
{/* TOTP Input */}
{showTotpField && (
<div className="relative">
<Input
id="totp"
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors text-center font-mono text-lg tracking-widest"
placeholder={t("totp_placeholder")}
autoComplete="one-time-code"
id="password"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="h-12 px-4 pr-11 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors"
placeholder={t("password_placeholder")}
required
autoComplete="current-password"
/>
)}
</div>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground hover:text-foreground transition-colors"
aria-label={showPassword ? t("hide_password") : t("show_password")}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="w-4.5 h-4.5" />
) : (
<Eye className="w-4.5 h-4.5" />
)}
</button>
</div>
{/* 2FA Checkbox */}
<div>
<label className="flex items-center gap-2.5 cursor-pointer group select-none">
<span className="relative flex items-center justify-center">
<input
type="checkbox"
checked={showTotpField}
onChange={(e) => {
setShowTotpField(e.target.checked);
if (!e.target.checked) setTotpCode("");
}}
className="peer sr-only"
/>
<span className="flex items-center justify-center w-4.5 h-4.5 rounded border border-border bg-secondary/50 peer-checked:bg-primary peer-checked:border-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background transition-colors">
{showTotpField && (
<svg className="w-3 h-3 text-primary-foreground" viewBox="0 0 12 12" fill="none">
<path d="M2 6L5 9L10 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
</span>
<span className="flex items-center gap-1.5 text-sm text-muted-foreground group-hover:text-foreground transition-colors">
<ShieldCheck className="w-4 h-4" />
{t("totp_checkbox")}
</span>
</label>
{!showTotpField && (
<p className="text-xs text-muted-foreground/80 mt-1.5 ml-7">
{t("totp_hint")}
</p>
)}
</div>
{/* TOTP Input with slide animation */}
<div
className="grid transition-all duration-200 ease-out"
style={{
gridTemplateRows: showTotpField ? '1fr' : '0fr',
opacity: showTotpField ? 1 : 0,
}}
>
<div className="overflow-hidden">
<Input
ref={totpInputRef}
id="totp"
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors text-center font-mono text-lg tracking-widest"
placeholder={t("totp_placeholder")}
autoComplete="one-time-code"
tabIndex={showTotpField ? 0 : -1}
aria-hidden={!showTotpField}
/>
</div>
</div>
</fieldset>
<Button
type="submit"
@@ -368,4 +457,4 @@ export default function LoginPage() {
</div>
</div>
);
}
}
+28 -2
View File
@@ -30,6 +30,8 @@ import {
import { DragDropProvider } from "@/contexts/drag-drop-context";
import { AdvancedSearchPanel } from "@/components/search/advanced-search-panel";
import { isFilterEmpty } from "@/lib/jmap/search-utils";
import { WelcomeBanner } from "@/components/ui/welcome-banner";
import { NavigationRail } from "@/components/layout/navigation-rail";
export default function Home() {
const router = useRouter();
@@ -37,6 +39,7 @@ export default function Home() {
const tCommon = useTranslations('common');
const [showComposer, setShowComposer] = useState(false);
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [composerDraftText, setComposerDraftText] = useState("");
const [initialCheckDone, setInitialCheckDone] = useState(false);
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
// Mobile conversation view state
@@ -395,7 +398,8 @@ export default function Home() {
}
};
const handleReply = () => {
const handleReply = (draftText?: string) => {
setComposerDraftText(draftText || "");
setComposerMode('reply');
setShowComposer(true);
};
@@ -718,6 +722,13 @@ export default function Home() {
return (
<DragDropProvider>
<div className="flex h-screen bg-background overflow-hidden">
{/* Desktop Navigation Rail */}
{!isMobile && !isTablet && (
<div className="w-14 border-r border-border bg-secondary flex flex-col items-center flex-shrink-0">
<NavigationRail collapsed />
</div>
)}
{/* Mobile/Tablet Sidebar Overlay Backdrop */}
{(isMobile || isTablet) && sidebarOpen && (
<div
@@ -760,7 +771,8 @@ export default function Home() {
</div>
{/* Main Content Area */}
<div className="flex flex-1 min-w-0 h-full">
<div className="flex flex-col flex-1 min-w-0 h-full">
<div className="flex flex-1 min-h-0">
{/* Email List - full width on mobile, fixed width on tablet/desktop */}
<div
className={cn(
@@ -796,6 +808,8 @@ export default function Home() {
onClose={toggleAdvancedSearch}
/>
<WelcomeBanner />
<ErrorBoundary fallback={EmailListErrorFallback}>
<EmailList
emails={emails}
@@ -917,6 +931,7 @@ export default function Home() {
setTabletListVisible(true);
selectEmail(null);
}}
onShowShortcuts={() => setShowShortcutsModal(true)}
currentUserEmail={client?.["username"]}
currentUserName={client?.["username"]?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
@@ -926,6 +941,12 @@ export default function Home() {
</>
)}
</div>
</div>
{/* Mobile Bottom Navigation */}
{isMobile && activeView !== "viewer" && (
<NavigationRail orientation="horizontal" />
)}
</div>
{/* Email Composer Modal */}
@@ -952,10 +973,12 @@ export default function Home() {
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
receivedAt: selectedEmail.receivedAt
} : undefined}
initialDraftText={composerDraftText}
onSend={handleEmailSend}
onClose={() => {
setShowComposer(false);
setComposerMode('compose');
setComposerDraftText("");
}}
onDiscardDraft={handleDiscardDraft}
/>
@@ -969,6 +992,9 @@ export default function Home() {
isOpen={showShortcutsModal}
onClose={() => setShowShortcutsModal(false)}
/>
{/* Screen reader live region for dynamic status announcements */}
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
</div>
</DragDropProvider>
);
+21
View File
@@ -296,6 +296,7 @@ body {
animation: slide-in 0.3s ease-out;
}
/* Mobile Responsive Utilities */
/* Safe area insets for notched devices (iPhone X+, etc.) */
@@ -385,3 +386,23 @@ body {
.animate-slide-in-from-left {
animation: slide-in-from-left 0.3s ease-out;
}
/* Reduced motion: respect user OS preference */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Disable backdrop-filter on mobile for performance */
@media (max-width: 767px) {
.mobile-backdrop {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
}
+3 -8
View File
@@ -2,7 +2,7 @@
import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button";
import { ArrowLeft, ChevronLeft, ChevronRight, Plus, Upload } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Upload } from "lucide-react";
import { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
@@ -10,7 +10,6 @@ import type { CalendarViewMode } from "@/stores/calendar-store";
interface CalendarToolbarProps {
selectedDate: Date;
viewMode: CalendarViewMode;
onNavigateBack: () => void;
onPrev: () => void;
onNext: () => void;
onToday: () => void;
@@ -19,12 +18,12 @@ interface CalendarToolbarProps {
onImport?: () => void;
isMobile?: boolean;
firstDayOfWeek?: number;
onNavigateBack?: () => void;
}
export function CalendarToolbar({
selectedDate,
viewMode,
onNavigateBack,
onPrev,
onNext,
onToday,
@@ -60,11 +59,6 @@ export function CalendarToolbar({
return (
<div className="flex items-center gap-2 px-4 py-3 border-b border-border flex-wrap">
<Button variant="ghost" size="sm" onClick={onNavigateBack} className="mr-1">
<ArrowLeft className="w-4 h-4 mr-1" />
{!isMobile && t("back_to_email")}
</Button>
<div className="flex items-center gap-1">
<button onClick={onPrev} className="p-1.5 rounded hover:bg-muted transition-colors" aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
@@ -89,6 +83,7 @@ export function CalendarToolbar({
<button
key={v}
onClick={() => onViewModeChange(v)}
title={t(`views.${v}_hint`)}
className={cn(
"px-3 py-1.5 text-xs font-medium transition-colors",
v === viewMode
+10 -7
View File
@@ -41,17 +41,17 @@ function parseDuration(duration: string): number {
return totalMinutes;
}
function createEventDragPreview(title: string, color: string): HTMLElement {
function createEventDragPreview(title: string, timeRange: string, color: string): HTMLElement {
const el = document.createElement("div");
el.style.cssText = `
position: fixed; top: -9999px; left: 0;
padding: 6px 12px; border-radius: 6px;
background: ${color}40; border-left: 3px solid ${color};
color: ${color}; font-size: 12px; font-weight: 500;
max-width: 200px; white-space: nowrap; overflow: hidden;
max-width: 240px; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; pointer-events: none; z-index: 9999;
`;
el.textContent = title;
el.textContent = `${title} \u2022 ${timeRange}`;
document.body.appendChild(el);
return el;
}
@@ -80,7 +80,7 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
}));
const displayTitle = event.title || t("events.no_title");
e.dataTransfer.setData("text/plain", displayTitle);
const preview = createEventDragPreview(displayTitle, color);
const preview = createEventDragPreview(displayTitle, timeString, color);
e.dataTransfer.setDragImage(preview, 0, 0);
requestAnimationFrame(() => preview.remove());
setIsBeingDragged(true);
@@ -135,13 +135,16 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color }}
>
<div className="font-medium truncate">{event.title || t("events.no_title")}</div>
{durationMinutes > 30 && (
{!event.showWithoutTime && (
<div className="opacity-80 text-[10px]">
{timeString}
</div>
)}
{durationMinutes > 30 && getParticipantCount(event) > 0 && (
<div className="flex items-center gap-0.5 opacity-70 text-[10px]">
{getParticipantCount(event) > 0 && (
<div
className="flex items-center gap-0.5 opacity-70 text-[10px]"
title={t("participants.count", { count: getParticipantCount(event) })}
>
<Users className="w-3 h-3" />
<span>{getParticipantCount(event)}</span>
</div>
+35 -15
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2, Check, HelpCircle, XCircle, Users } from "lucide-react";
import { X, Trash2, Check, Users, CalendarDays } from "lucide-react";
import { format, parseISO, addHours } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration } from "./event-card";
@@ -94,6 +94,12 @@ export function EventModal({
return getParticipantList(event);
}, [event]);
const organizerInfo = useMemo(() => {
if (!event?.participants) return null;
const organizer = existingParticipants.find(p => p.isOrganizer);
return organizer ? { name: organizer.name, email: organizer.email } : null;
}, [event, existingParticipants]);
const getInitialStart = (): Date => {
if (event?.start) return parseISO(event.start);
if (defaultDate) {
@@ -342,6 +348,18 @@ export function EventModal({
</div>
<div className="px-5 py-4 space-y-3">
<div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3">
<CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium text-blue-900 dark:text-blue-200">
{t("participants.invited_by", { name: organizerInfo?.name || organizerInfo?.email || t("participants.organizer") })}
</p>
<p className="text-blue-700 dark:text-blue-400 mt-0.5">
{t("participants.respond_below")}
</p>
</div>
</div>
<div className="text-sm">
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span>
{!event.showWithoutTime && (
@@ -359,10 +377,6 @@ export function EventModal({
<p className="text-sm text-muted-foreground">{locationName}</p>
)}
<div className="text-xs text-muted-foreground">
{t("participants.you_attendee")}
</div>
{participants.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-sm font-medium">
@@ -383,33 +397,39 @@ export function EventModal({
<div className="px-5 py-4 border-t border-border">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">RSVP</span>
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
<div className="flex gap-2">
<Button
size="sm"
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
onClick={() => handleRsvp("accepted")}
className={userCurrentStatus === "accepted" ? "bg-green-600 hover:bg-green-700 text-white ring-2 ring-green-300 dark:ring-green-700" : "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"}
className={userCurrentStatus === "accepted"
? "bg-green-600 hover:bg-green-700 text-white dark:bg-green-500 dark:hover:bg-green-600"
: "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"}
>
<Check className="w-4 h-4 mr-1" />
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
{t("participants.accepted")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
onClick={() => handleRsvp("tentative")}
className={userCurrentStatus === "tentative" ? "bg-amber-600 hover:bg-amber-700 text-white ring-2 ring-amber-300 dark:ring-amber-700" : "text-amber-600 dark:text-amber-400 border-amber-300 dark:border-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950"}
className={userCurrentStatus === "tentative"
? "bg-amber-600 hover:bg-amber-700 text-white dark:bg-amber-500 dark:hover:bg-amber-600"
: "border border-amber-500 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950"}
>
<HelpCircle className="w-4 h-4 mr-1" />
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
{t("participants.tentative")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "declined" ? "default" : "outline"}
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
onClick={() => handleRsvp("declined")}
className={userCurrentStatus === "declined" ? "bg-red-600 hover:bg-red-700 text-white ring-2 ring-red-300 dark:ring-red-700" : "text-red-600 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-50 dark:hover:bg-red-950"}
className={userCurrentStatus === "declined"
? "bg-red-600 hover:bg-red-700 text-white dark:bg-red-500 dark:hover:bg-red-600"
: "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"}
>
<XCircle className="w-4 h-4 mr-1" />
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
{t("participants.declined")}
</Button>
</div>
@@ -500,7 +520,7 @@ export function EventModal({
<label htmlFor="allDay" className="text-sm">{t("form.all_day_event")}</label>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium mb-1 block">{t("form.start_date")}</label>
<input
@@ -560,7 +580,7 @@ export function EventModal({
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<select
+6 -2
View File
@@ -2,7 +2,7 @@
import { useState, useMemo } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
addMonths, subMonths, addYears, subYears, setMonth, setYear,
@@ -113,13 +113,17 @@ export function MiniCalendar({
<button
onClick={handleHeaderClick}
disabled={pickerView === "years"}
title={pickerView !== "years" ? t("mini_calendar_change") : undefined}
className={cn(
"text-sm font-medium px-1 rounded transition-colors",
"text-sm font-medium px-2 py-1 rounded-md transition-colors inline-flex items-center gap-1",
pickerView !== "years" && "hover:bg-muted cursor-pointer",
pickerView === "years" && "cursor-default"
)}
>
{headerLabel}
{pickerView !== "years" && (
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
<button
onClick={handleNext}
@@ -1,8 +1,16 @@
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 type { ContactCard } from '@/lib/jmap/types';
beforeEach(() => {
Object.assign(navigator, {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
});
});
const contact: ContactCard = {
id: '1',
addressBookIds: {},
@@ -52,10 +60,10 @@ describe('ContactDetail', () => {
it('calls onDelete when delete button is clicked', () => {
const onDelete = vi.fn();
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
const trashButtons = screen.getAllByRole('button').filter(
btn => btn.querySelector('svg') && btn.textContent?.trim() === ''
const deleteButton = screen.getAllByRole('button').find(
btn => btn.className.includes('text-red')
);
fireEvent.click(trashButtons[trashButtons.length - 1]);
fireEvent.click(deleteButton!);
expect(onDelete).toHaveBeenCalledOnce();
});
});
@@ -62,7 +62,7 @@ describe('ContactList', () => {
it('shows empty state when no contacts match', () => {
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', () => {
+44 -3
View File
@@ -1,12 +1,13 @@
"use client";
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 { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
interface ContactDetailProps {
contact: ContactCard | null;
@@ -64,13 +65,38 @@ export function ContactDetail({ contact, onEdit, onDelete, className }: ContactD
{emails.length > 0 && (
<Section icon={Mail} title={t("detail.emails")}>
{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">
{e.address}
</a>
{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>
))}
</Section>
@@ -79,13 +105,28 @@ export function ContactDetail({ contact, onEdit, onDelete, className }: ContactD
{phones.length > 0 && (
<Section icon={Phone} title={t("detail.phones")}>
{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">
{p.number}
</a>
{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>
))}
</Section>
+43 -5
View File
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { X, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
interface EmailEntry {
@@ -63,6 +64,24 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
const [isSaving, setIsSaving] = useState(false);
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) => {
e.preventDefault();
@@ -145,9 +164,11 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-sm 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
value={givenName}
onChange={(e) => setGivenName(e.target.value)}
@@ -156,7 +177,9 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
/>
</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
value={surname}
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>
<div className="space-y-2">
{emails.map((entry, i) => (
<div key={i} className="flex items-center gap-2">
<div key={i}>
<div className="flex items-center gap-2">
<Input
type="email"
inputMode="email"
value={entry.address}
onChange={(e) => {
const next = [...emails];
next[i] = { ...next[i], address: e.target.value };
setEmails(next);
if (emailErrors[i]) {
setEmailErrors(prev => {
const n = { ...prev };
delete n[i];
return n;
});
}
}}
onBlur={() => handleEmailBlur(i, entry.address)}
placeholder={t("email_placeholder")}
className="flex-1"
className={cn("flex-1", emailErrors[i] && "border-red-500 focus:ring-red-500")}
/>
<select
value={entry.context}
@@ -205,6 +238,10 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
<X className="w-3 h-3" />
</Button>
)}
</div>
{emailErrors[i] && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">{emailErrors[i]}</p>
)}
</div>
))}
<Button
@@ -226,6 +263,7 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
<div key={i} className="flex items-center gap-2">
<Input
type="tel"
inputMode="tel"
value={entry.number}
onChange={(e) => {
const next = [...phones];
+37 -6
View File
@@ -2,7 +2,7 @@
import { useMemo } from "react";
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 { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
@@ -17,6 +17,7 @@ interface ContactListProps {
onSearchChange: (query: string) => void;
onSelectContact: (id: string) => void;
onCreateNew: () => void;
onImport?: () => void;
supportsSync: boolean;
className?: string;
selectedContactIds: Set<string>;
@@ -35,6 +36,7 @@ export function ContactList({
onSearchChange,
onSelectContact,
onCreateNew,
onImport,
supportsSync,
className,
selectedContactIds,
@@ -158,11 +160,40 @@ export function ContactList({
<div className="flex-1 overflow-y-auto">
{sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground px-4">
<BookUser className="w-12 h-12 mb-3 opacity-30" />
<p className="text-sm">
{searchQuery ? t("empty_search") : t("empty_state")}
</p>
<div className="flex flex-col items-center justify-center h-full px-6 text-center">
{searchQuery ? (
<>
<Search className="w-12 h-12 mb-3 text-muted-foreground/30" />
<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 className="divide-y divide-border">
+175 -87
View File
@@ -1,12 +1,16 @@
"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 { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
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 { 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 { useContactStore } from "@/stores/contact-store";
import { useTemplateStore } from "@/stores/template-store";
@@ -27,10 +31,11 @@ interface EmailComposerProps {
draftId?: string;
fromEmail?: string;
identityId?: string;
}) => void;
}) => void | Promise<void>;
onClose?: () => void;
onDiscardDraft?: (draftId: string) => void;
className?: string;
initialDraftText?: string;
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: {
from?: { email?: string; name?: string }[];
@@ -47,6 +52,7 @@ export function EmailComposer({
onClose,
onDiscardDraft,
className,
initialDraftText,
mode = 'compose',
replyTo
}: EmailComposerProps) {
@@ -84,18 +90,19 @@ export function EmailComposer({
};
const getInitialBody = () => {
if (!replyTo?.body) return "";
const prefix = initialDraftText || "";
if (!replyTo?.body) return prefix;
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : "";
const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
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') {
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());
@@ -109,12 +116,15 @@ export function EmailComposer({
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
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 [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
const [shakeField, setShakeField] = useState<string | null>(null);
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
const [subAddressTag, setSubAddressTag] = useState<string>('');
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog();
const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate,
@@ -132,6 +142,9 @@ export function EmailComposer({
const toInputRef = useRef<HTMLInputElement>(null);
const ccInputRef = 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') => {
if (autocompleteTimeoutRef.current) {
@@ -170,6 +183,18 @@ export function EmailComposer({
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') => {
if (!activeAutoField || autocompleteResults.length === 0) return;
@@ -235,52 +260,57 @@ export function EmailComposer({
return () => window.removeEventListener('keydown', handleTemplateKey);
}, []);
// Handle file selection
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!client || !event.target.files) return;
const files = Array.from(event.target.files);
// Add files to attachments list with uploading state
const newAttachments = files.map(file => ({ file, uploading: true }));
// AbortController tracks cancellation state but uploadBlob doesn't accept a signal,
// 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]);
// Upload each file
for (let i = 0; i < files.length; i++) {
const file = files[i];
const controller = newAttachments[i].abortController;
try {
if (controller?.signal.aborted) continue;
const { blobId } = await client.uploadBlob(file);
// Update attachment with blobId
if (controller?.signal.aborted) continue;
setAttachments(prev =>
prev.map(att =>
att.file === file
? { ...att, blobId, uploading: false }
? { ...att, blobId, uploading: false, abortController: undefined }
: att
)
);
} 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 =>
prev.map(att =>
att.file === file
? { ...att, uploading: false, error: true }
? { ...att, uploading: false, error: true, abortController: undefined }
: att
)
);
}
}
// Clear the input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// Remove attachment
const removeAttachment = (index: number) => {
const att = attachments[index];
att?.abortController?.abort();
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 bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
// Only save if there's some content
if (!toAddresses.length && !subject && !body) {
return null;
}
@@ -392,39 +421,62 @@ export function EmailComposer({
};
}, []);
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 toAddresses = to.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);
// Allow sending if we have recipient, subject, and either body text or attachments
const hasContent = body || attachments.some(att => att.blobId && !att.uploading);
if (!canSend) {
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 (toAddresses.length > 0 && subject && hasContent) {
// Wait for any pending auto-save to complete and get the latest draft ID
let finalDraftId = draftId;
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
// saveDraft returns the new draft ID after destroy+create
if (errors.to) {
setShakeField('to');
setTimeout(() => setShakeField(null), 400);
toInputRef.current?.focus();
}
return;
}
let finalDraftId = draftId;
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
try {
const savedId = await saveDraft();
if (savedId) {
finalDraftId = savedId;
}
} catch (err) {
debug.error('Failed to save draft before send:', err);
}
}
// Get the selected identity or primary identity
const currentIdentity = selectedIdentityId
? identities.find(id => id.id === selectedIdentityId)
: primaryIdentity;
const currentIdentity = selectedIdentityId
? identities.find(id => id.id === selectedIdentityId)
: primaryIdentity;
// Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag)
: currentIdentity.email
: undefined;
const fromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag)
: currentIdentity.email
: undefined;
onSend?.({
try {
await onSend?.({
to: toAddresses,
cc: ccAddresses,
bcc: bccAddresses,
@@ -435,7 +487,6 @@ export function EmailComposer({
identityId: currentIdentity?.id,
});
// Reset form
setTo("");
setCc("");
setBcc("");
@@ -443,21 +494,27 @@ export function EmailComposer({
setBody("");
setDraftId(null);
setSubAddressTag("");
setValidationErrors({});
} catch (err) {
debug.error('Failed to send email:', err);
toast.error(t('send_failed'));
}
};
const handleClose = () => {
// If there's a draft with content, ask user if they want to discard
const handleClose = async () => {
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) {
// Clear any pending auto-save
if (confirmed) {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Delete the draft if callback is provided
if (onDiscardDraft) {
onDiscardDraft(draftId);
}
@@ -555,7 +612,7 @@ export function EmailComposer({
</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>
<div className="flex-1 relative">
<Input
@@ -565,19 +622,27 @@ export function EmailComposer({
value={to}
onChange={(e) => {
setTo(e.target.value);
if (validationErrors.to) setValidationErrors(prev => ({ ...prev, to: false }));
handleAutocomplete(e.target.value, 'to');
}}
onKeyDown={(e) => handleAutoKeyDown(e, 'to')}
onBlur={() => setTimeout(() => { if (activeAutoField === 'to') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)}
className="border-0 focus-visible:ring-0"
onBlur={(e) => handleAutoBlur(e, 'to')}
className={cn(
"border-0 focus-visible:ring-0",
validationErrors.to && "ring-2 ring-red-500 dark:ring-red-400"
)}
role="combobox"
aria-expanded={activeAutoField === 'to' && autocompleteResults.length > 0}
aria-autocomplete="list"
aria-controls={activeAutoField === 'to' ? 'autocomplete-to' : 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 && (
<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 className="flex gap-1">
@@ -614,7 +679,7 @@ export function EmailComposer({
handleAutocomplete(e.target.value, '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"
role="combobox"
aria-expanded={activeAutoField === 'cc' && autocompleteResults.length > 0}
@@ -623,7 +688,7 @@ export function EmailComposer({
aria-activedescendant={activeAutoField === 'cc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
/>
{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>
@@ -643,7 +708,7 @@ export function EmailComposer({
handleAutocomplete(e.target.value, '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"
role="combobox"
aria-expanded={activeAutoField === 'bcc' && autocompleteResults.length > 0}
@@ -652,7 +717,7 @@ export function EmailComposer({
aria-activedescendant={activeAutoField === 'bcc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
/>
{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>
@@ -664,22 +729,35 @@ export function EmailComposer({
type="text"
placeholder={t('subject_placeholder')}
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="flex-1 border-0 focus-visible:ring-0"
onChange={(e) => {
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 className="flex-1 px-4 py-3 min-h-0">
<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')}
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>
{/* Attachments display */}
{attachments.length > 0 && (
<div className="px-4 py-2 border-t">
<div className="flex flex-wrap gap-2">
@@ -687,27 +765,36 @@ export function EmailComposer({
<div
key={index}
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.uploading ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : att.error ? (
<AlertCircle className="w-3 h-3" />
) : (
<Paperclip className="w-3 h-3" />
{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>
)}
<span className="max-w-[200px] truncate">{att.file.name}</span>
<span className="text-xs text-muted-foreground">
({(att.file.size / 1024).toFixed(1)} {t('file_size_kb')})
</span>
<button
onClick={() => removeAttachment(index)}
className="ml-1 hover:text-red-500"
>
<X className="w-3 h-3" />
</button>
<div className="relative flex items-center gap-2">
{att.uploading ? (
<Loader2 className="w-3 h-3 animate-spin flex-shrink-0" />
) : att.error ? (
<AlertCircle className="w-3 h-3 flex-shrink-0" />
) : (
<Paperclip className="w-3 h-3 flex-shrink-0" />
)}
<span className="max-w-[200px] truncate">{att.file.name}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
({formatFileSize(att.file.size)})
</span>
<button
onClick={() => removeAttachment(index)}
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" />
</button>
</div>
</div>
))}
</div>
@@ -759,7 +846,11 @@ export function EmailComposer({
<Paperclip className="w-4 h-4 mr-2" />
{t('attach')}
</Button>
<Button onClick={handleSend}>
<Button
onClick={handleSend}
disabled={!canSend}
title={getSendTooltip()}
>
<Send className="w-4 h-4 mr-2" />
{t('send')}
</Button>
@@ -801,23 +892,20 @@ export function EmailComposer({
</div>
</div>
)}
<ConfirmDialog {...confirmDialogProps} />
</div>
);
}
function AutocompleteDropdown({
id,
results,
selectedIndex,
onSelect,
}: {
const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
id: string;
results: Array<{ name: string; email: string }>;
selectedIndex: number;
onSelect: (email: string) => void;
}) {
}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect }, ref) {
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) => (
<button
key={i}
@@ -842,4 +930,4 @@ function AutocompleteDropdown({
))}
</div>
);
}
});
+26 -3
View File
@@ -273,20 +273,43 @@ export function EmailContextMenu({
{/* Set color submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Palette} label={t("color_tag")}>
<div className="px-3 py-2 flex flex-wrap gap-1.5">
{colorOptions.map((option) => (
<div
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
key={option.value}
tabIndex={i === 0 ? 0 : -1}
onClick={() =>
handleAction(() => onSetColorTag?.(option.value))
}
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,
currentColor === option.value &&
"ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
title={option.name}
aria-label={option.name}
/>
))}
</div>
+1 -1
View File
@@ -100,7 +100,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
<button
onClick={handleCheckboxClick}
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",
"active:scale-95",
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 { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useTranslations } from "next-intl";
import { useVirtualizer } from "@tanstack/react-virtual";
import { SearchChips } from "@/components/search/search-chips";
@@ -90,6 +92,7 @@ export function EmailList({
}, [emails]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const [isProcessing, setIsProcessing] = useState(false);
const parentRef = useRef<HTMLDivElement>(null);
@@ -143,7 +146,16 @@ export function EmailList({
};
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);
try {
await batchDelete(client);
@@ -456,6 +468,8 @@ export function EmailList({
}}
/>
)}
<ConfirmDialog {...confirmDialogProps} />
</div>
);
}
+41 -18
View File
@@ -47,6 +47,7 @@ import {
Copy,
Brain,
Sparkles,
Keyboard,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
@@ -63,7 +64,7 @@ import { findCalendarAttachment } from "@/lib/calendar-invitation";
interface EmailViewerProps {
email: Email | null;
isLoading?: boolean;
onReply?: () => void;
onReply?: (draftText?: string) => void;
onReplyAll?: () => void;
onForward?: () => void;
onDelete?: () => void;
@@ -76,6 +77,7 @@ interface EmailViewerProps {
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onBack?: () => void;
onShowShortcuts?: () => void;
currentUserEmail?: string;
currentUserName?: string;
currentMailboxRole?: string;
@@ -170,6 +172,7 @@ export function EmailViewer({
onMarkAsSpam,
onUndoSpam,
onBack,
onShowShortcuts,
currentUserEmail,
currentUserName,
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 (htmlNode.style) {
const originalStyles = htmlNode.style.cssText;
@@ -512,7 +520,7 @@ export function EmailViewer({
.replace(/\r/g, '<br>') // Old Mac line endings
.replace(/\n/g, '<br>') // Unix line endings
.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 {
html: htmlFromText,
@@ -650,7 +658,7 @@ export function EmailViewer({
variant="ghost"
size="icon"
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')}
>
<ChevronLeft className="w-5 h-5" />
@@ -697,9 +705,9 @@ export function EmailViewer({
)}
{/* Primary Reply Button */}
<Button
onClick={onReply}
onClick={() => onReply?.()}
size="sm"
className="mr-1 h-8 lg:h-9"
className="mr-1 h-10 lg:h-9"
title={t('tooltips.reply')}
>
<Reply className="w-4 h-4" />
@@ -711,7 +719,7 @@ export function EmailViewer({
<Button
variant="ghost"
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')}
>
<ChevronDown className="w-4 h-4 text-muted-foreground" />
@@ -740,7 +748,7 @@ export function EmailViewer({
variant="ghost"
size="icon"
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')}
>
<Archive className="w-4 h-4 text-muted-foreground" />
@@ -753,7 +761,7 @@ export function EmailViewer({
size="icon"
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
className={cn(
"hidden h-8 w-8 lg:flex",
"hidden h-10 w-10 lg:h-8 lg:w-8 lg:flex",
isInJunkFolder
? "hover:bg-green-50 dark:hover:bg-green-950/30"
: "hover:bg-red-50 dark:hover:bg-red-950/30"
@@ -772,7 +780,7 @@ export function EmailViewer({
variant="ghost"
size="icon"
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')}
>
<Trash2 className="w-4 h-4 text-muted-foreground" />
@@ -781,7 +789,7 @@ export function EmailViewer({
variant="ghost"
size="icon"
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"}
>
<Star className={cn(
@@ -855,7 +863,7 @@ export function EmailViewer({
<Button
variant="ghost"
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')}
>
<MoreVertical className="w-4 h-4 text-muted-foreground" />
@@ -875,6 +883,15 @@ export function EmailViewer({
<Printer className="w-4 h-4" />
{t('print')}
</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 */}
<div className="h-px bg-border my-1" />
{/* Spam action - contextual */}
@@ -1315,7 +1332,7 @@ export function EmailViewer({
<div className="flex flex-col gap-3 isolate">
{/* External Content Controls */}
{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' && (
<button
onClick={() => setAllowExternalContent(true)}
@@ -1344,7 +1361,7 @@ export function EmailViewer({
{/* Unsubscribe Controls */}
{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
listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''}
@@ -1360,7 +1377,9 @@ export function EmailViewer({
{/* Calendar Invitation Banner */}
{hasCalendarInvitation && (
<CalendarInvitationBanner email={email} />
<div className="rounded-md px-3 py-1 bg-amber-50/50 dark:bg-amber-950/20">
<CalendarInvitationBanner email={email} />
</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",
"resize-none"
)}
rows={isQuickReplyFocused || quickReplyText ? 3 : 1}
rows={isQuickReplyFocused || quickReplyText ? 3 : 2}
disabled={isSendingQuickReply}
/>
@@ -1511,7 +1530,7 @@ export function EmailViewer({
{(isQuickReplyFocused || quickReplyText) && (
<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">
{quickReplyText.length > 0 && t('characters_count', { count: quickReplyText.length })}
{t('characters_count', { count: quickReplyText.length })}
</div>
<div className="flex items-center gap-2">
<Button
@@ -1528,7 +1547,11 @@ export function EmailViewer({
<Button
variant="ghost"
size="sm"
onClick={onReply}
onClick={() => {
onReply?.(quickReplyText);
setQuickReplyText("");
setIsQuickReplyFocused(false);
}}
disabled={isSendingQuickReply}
className="text-muted-foreground"
>
@@ -1605,7 +1628,7 @@ export function EmailViewer({
variant="ghost"
size="icon"
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" />
</Button>
@@ -297,6 +297,11 @@ function EmailCard({
}
}
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
if (resolvedTheme === 'dark') {
if (htmlNode.style) {
const originalStyles = htmlNode.style.cssText;
+13 -7
View File
@@ -5,7 +5,7 @@ import { formatDate } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
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 { useUIStore } from "@/stores/ui-store";
import { getThreadColorTag } from "@/lib/thread-utils";
@@ -244,6 +244,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
"active:scale-95",
"text-muted-foreground hover:text-foreground"
)}
aria-expanded={isExpanded}
aria-label={t('toggle_thread')}
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
@@ -279,12 +281,16 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}>
{participantNames.join(", ")}
</span>
<span className={cn(
"flex-shrink-0 px-1.5 py-0.5 text-xs rounded-full font-medium",
hasUnread
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}>
<span
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
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}
title={t('messages_tooltip', { count: emailCount })}
>
<MessageSquare className="w-3 h-3" />
{emailCount}
</span>
<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 { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { IdentityForm } from './identity-form';
import { useIdentityStore } from '@/stores/identity-store';
import { useAuthStore } from '@/stores/auth-store';
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { toast } from '@/stores/toast-store';
import { useFocusTrap } from '@/hooks/use-focus-trap';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
interface IdentityFormData {
name: string;
@@ -36,6 +38,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
// Focus trap with Escape handling
const modalRef = useFocusTrap({
@@ -117,9 +120,13 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
return;
}
if (!window.confirm(t('delete_confirm'))) {
return;
}
const confirmed = await confirmDialog({
title: t('delete_confirm_title'),
message: t('delete_confirm'),
confirmText: t('delete_button'),
variant: "destructive",
});
if (!confirmed) return;
setDeletingId(identity.id);
@@ -133,7 +140,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
} finally {
setDeletingId(null);
}
}, [client, removeIdentity, t, tNotif]);
}, [client, removeIdentity, t, tNotif, confirmDialog]);
if (!isOpen) return null;
@@ -297,6 +304,8 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
</div>
</div>
</div>
<ConfirmDialog {...confirmDialogProps} />
</div>
);
}
+17 -31
View File
@@ -1,10 +1,10 @@
"use client";
import { useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { X, Keyboard } from "lucide-react";
import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts";
import { cn } from "@/lib/utils";
import { useFocusTrap } from "@/hooks/use-focus-trap";
interface KeyboardShortcutsModalProps {
isOpen: boolean;
@@ -13,43 +13,28 @@ interface KeyboardShortcutsModalProps {
export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) {
const t = useTranslations();
const modalRef = useRef<HTMLDivElement>(null);
// Close on any key press
useEffect(() => {
const handleKeyDown = () => {
onClose();
};
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]);
const modalRef = useFocusTrap({
isActive: isOpen,
onEscape: onClose,
restoreFocus: true,
});
if (!isOpen) return null;
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
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-dialog-title"
className={cn(
"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"
)}
>
@@ -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 gap-3">
<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")}
</h2>
</div>
<button
onClick={onClose}
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" />
</button>
</div>
{/* 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">
{/* Navigation Section */}
<section>
+8 -4
View File
@@ -50,8 +50,12 @@ export function MobileHeader({
variant="ghost"
size="icon"
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-expanded={!showBack ? sidebarOpen : undefined}
>
{showBack ? (
<ArrowLeft className="h-5 w-5" />
@@ -73,7 +77,7 @@ export function MobileHeader({
variant="ghost"
size="icon"
onClick={onSearch}
className="h-10 w-10"
className="h-11 w-11"
aria-label={t('mobile.search')}
>
<Search className="h-5 w-5" />
@@ -84,7 +88,7 @@ export function MobileHeader({
variant="ghost"
size="icon"
onClick={onCompose}
className="h-10 w-10 text-primary"
className="h-11 w-11 text-primary"
aria-label={t('mobile.compose')}
>
<Plus className="h-5 w-5" />
@@ -127,7 +131,7 @@ export function MobileViewerHeader({
variant="ghost"
size="icon"
onClick={onBack}
className="h-10 w-10"
className="h-11 w-11"
aria-label={t('mobile.go_back')}
>
<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>
);
}
+112 -178
View File
@@ -20,14 +20,11 @@ import {
ChevronDown,
Folder,
FolderOpen,
Settings,
ChevronUp,
Users,
User,
BookUser,
Palmtree,
SlidersHorizontal,
Calendar,
Settings,
X,
} from "lucide-react";
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 { activeFilterCount } from "@/lib/jmap/search-utils";
import { useVacationStore } from "@/stores/vacation-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
interface SidebarProps {
mailboxes: Mailbox[];
@@ -55,27 +52,22 @@ interface SidebarProps {
className?: string;
}
// Map role to icon
const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => {
const lowerName = name?.toLowerCase() || "";
// Shared folders root node
if (id === 'shared-folders-root') {
return isExpanded ? FolderOpen : Users;
}
// Shared account nodes
if (id?.startsWith('shared-account-')) {
return isExpanded ? FolderOpen : User;
}
// Shared mailboxes (but not virtual nodes)
if (isShared && hasChildren && !id?.startsWith('shared-')) {
return isExpanded ? FolderOpen : Folder;
}
if (hasChildren) {
// For folders with children, return open/closed folder icon
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 === "archive" || lowerName.includes("archive")) return Archive;
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({
node,
selectedMailbox,
@@ -109,10 +100,9 @@ function MailboxTreeItem({
const hasChildren = node.children.length > 0;
const isExpanded = expandedFolders.has(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 isVirtualNode = node.id.startsWith('shared-'); // Virtual nodes for shared folder organization
const indentPixels = node.depth * 16;
const isVirtualNode = node.id.startsWith('shared-');
// Drag and drop functionality
const { isDragging: globalDragging } = useDragDropContext();
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
mailbox: node,
@@ -140,15 +130,16 @@ function MailboxTreeItem({
{...(globalDragging ? dropHandlers : {})}
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",
selectedMailbox === node.id
? "bg-accent text-accent-foreground"
: "hover:bg-muted text-foreground",
node.depth === 0 && "font-medium",
isVirtualNode
? "text-muted-foreground"
: selectedMailbox === node.id
? "bg-accent text-accent-foreground"
: "hover:bg-muted text-foreground",
node.depth === 0 && !isVirtualNode && "font-medium",
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset",
isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50"
)}
>
{/* Expand/Collapse Chevron */}
{hasChildren && (
<button
onClick={(e) => {
@@ -170,14 +161,13 @@ function MailboxTreeItem({
</button>
)}
{/* Mailbox Button */}
<button
onClick={() => !isVirtualNode && onMailboxSelect?.(node.id)}
disabled={isVirtualNode}
className={cn(
"flex-1 flex items-center text-left py-1 lg:py-1 max-lg:py-2 px-1 rounded",
"transition-colors duration-150",
isVirtualNode && "cursor-default"
isVirtualNode && "cursor-default select-none"
)}
style={{
paddingLeft: hasChildren ? '4px' : `${indentPixels + 24}px`
@@ -189,7 +179,7 @@ function MailboxTreeItem({
hasChildren && isExpanded && "text-primary",
selectedMailbox === node.id && "text-accent-foreground",
!hasChildren && node.depth > 0 && "text-muted-foreground",
node.isShared && "text-blue-500" // Shared folders in blue
node.isShared && "text-blue-500"
)} />
{!isCollapsed && (
<>
@@ -209,7 +199,6 @@ function MailboxTreeItem({
</button>
</div>
{/* Render children if expanded */}
{hasChildren && isExpanded && !isCollapsed && (
<div className="relative">
{node.children.map((child) => (
@@ -229,27 +218,26 @@ function MailboxTreeItem({
);
}
function VacationIndicator() {
function VacationBanner() {
const t = useTranslations('sidebar');
const router = useRouter();
const { isEnabled, isSupported } = useVacationStore();
if (!isSupported || !isEnabled) return null;
return (
<span
className="relative group"
title={t("vacation_active")}
<button
onClick={() => router.push('/settings')}
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" />
<span className={cn(
"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",
"whitespace-nowrap opacity-0 group-hover:opacity-100",
"pointer-events-none transition-opacity duration-200 z-50"
)}>
{t("vacation_active")}
</span>
</span>
<Palmtree className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate font-medium">{t("vacation_active")}</span>
<Settings className="w-3 h-3 ml-auto flex-shrink-0 opacity-60" />
</button>
);
}
@@ -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({
mailboxes = [],
selectedMailbox = "",
@@ -297,17 +322,12 @@ export function Sidebar({
const [isCollapsed, setIsCollapsed] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [showMenu, setShowMenu] = useState(false);
const t = useTranslations('sidebar');
const { supportsCalendar } = useCalendarStore();
// Sync local search query with store's active search query
useEffect(() => {
setSearchQuery(activeSearchQuery);
}, [activeSearchQuery]);
const router = useRouter();
// Load expanded folders from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem('expandedMailboxes');
if (stored) {
@@ -315,10 +335,9 @@ export function Sidebar({
const parsed = JSON.parse(stored);
setExpandedFolders(new Set(parsed));
} catch (e) {
console.error('Failed to parse expanded mailboxes:', e);
debug.error('Failed to parse expanded mailboxes:', e);
}
} else {
// By default, expand root folders that have children
const tree = buildMailboxTree(mailboxes);
const defaultExpanded = tree
.filter(node => node.children.length > 0)
@@ -327,7 +346,6 @@ export function Sidebar({
}
}, [mailboxes]);
// Save expanded folders to localStorage when changed
const handleToggleExpand = (mailboxId: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
@@ -336,7 +354,9 @@ export function Sidebar({
} else {
next.add(mailboxId);
}
localStorage.setItem('expandedMailboxes', JSON.stringify(Array.from(next)));
try {
localStorage.setItem('expandedMailboxes', JSON.stringify(Array.from(next)));
} catch { /* storage full or unavailable */ }
return next;
});
};
@@ -348,15 +368,12 @@ export function Sidebar({
}
};
// Build hierarchical mailbox tree
const mailboxTree = buildMailboxTree(mailboxes);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!selectedMailbox || isCollapsed) return;
// Find the selected node in the tree
const findNode = (nodes: MailboxNode[]): MailboxNode | null => {
for (const node of nodes) {
if (node.id === selectedMailbox) return node;
@@ -369,14 +386,11 @@ export function Sidebar({
const selectedNode = findNode(mailboxTree);
if (!selectedNode) return;
// Handle arrow keys for expand/collapse
if (e.key === 'ArrowRight' && selectedNode.children.length > 0) {
// Expand folder
if (!expandedFolders.has(selectedMailbox)) {
handleToggleExpand(selectedMailbox);
}
} else if (e.key === 'ArrowLeft' && selectedNode.children.length > 0) {
// Collapse folder
if (expandedFolders.has(selectedMailbox)) {
handleToggleExpand(selectedMailbox);
}
@@ -399,7 +413,6 @@ export function Sidebar({
>
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
{/* Mobile/Tablet: Close button */}
<Button
variant="ghost"
size="icon"
@@ -410,7 +423,6 @@ export function Sidebar({
<X className="w-5 h-5" />
</Button>
{/* Desktop: Collapse toggle */}
<Button
variant="ghost"
size="icon"
@@ -421,13 +433,16 @@ export function Sidebar({
</Button>
{!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" />
{t("compose")}
</Button>
)}
</div>
{/* Vacation Banner */}
{!isCollapsed && <VacationBanner />}
{/* Search + Advanced Filter Toggle */}
{!isCollapsed && (
<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" />
<Input
type="text"
placeholder={t("search_placeholder")}
placeholder={t("search_placeholder_hint")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn("pl-9", searchQuery && "pr-8")}
@@ -470,7 +485,6 @@ export function Sidebar({
</div>
) : (
<>
{/* Render hierarchical mailbox tree */}
{mailboxTree.map((node) => (
<MailboxTreeItem
key={node.id}
@@ -487,131 +501,51 @@ export function Sidebar({
</div>
</div>
{/* Footer */}
{!isCollapsed && (
<>
{/* Sliding Menu Panel */}
<div className={cn(
"absolute bottom-0 left-0 right-0 bg-background border-t border-border z-10 shadow-lg",
"transform transition-all duration-300 ease-out",
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>
)}
{/* Footer: Storage Quota + Sign Out + Push Status */}
<div className="border-t border-border">
<StorageQuota quota={quota ?? null} isCollapsed={isCollapsed} />
<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 && (
<button
onClick={onLogout}
className="w-full px-4 py-2 flex items-center gap-2 hover:bg-muted transition-colors text-sm"
>
<LogOut className="w-4 h-4" />
{t("sign_out")}
</button>
)}
</div>
</div>
</div>
{/* Menu Toggle Button */}
<div className="border-t border-border relative">
<div className={cn(
"flex items-center border-t border-border",
isCollapsed ? "justify-center py-2" : "justify-between px-3 py-2"
)}>
{onLogout && (
<button
onClick={() => setShowMenu(!showMenu)}
onClick={onLogout}
className={cn(
"w-full px-4 py-3 flex items-center justify-between",
"hover:bg-muted transition-colors",
"text-sm text-foreground"
"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")}
>
<span className="flex items-center gap-2">
<Menu className="w-4 h-4" />
Menu
<VacationIndicator />
{/* Push Connection Status Indicator */}
<span
className="relative group"
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
>
<span
className={cn(
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
)}
/>
{/* Tooltip on hover */}
<span className={cn(
"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",
"whitespace-nowrap opacity-0 group-hover:opacity-100",
"pointer-events-none transition-opacity duration-200 z-50"
)}>
{isPushConnected ? t("push_connected") : t("push_disconnected")}
</span>
</span>
</span>
<ChevronUp className={cn(
"w-4 h-4 transition-transform duration-200",
showMenu ? "" : "rotate-180"
)} />
<LogOut className="w-4 h-4" />
{!isCollapsed && t("sign_out")}
</button>
</div>
</>
)}
)}
{!isCollapsed && (
<span
className="relative group"
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
>
<span
className={cn(
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
)}
/>
<span className={cn(
"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",
"whitespace-nowrap opacity-0 group-hover:opacity-100",
"pointer-events-none transition-opacity duration-200 z-50"
)}>
{isPushConnected ? t("push_connected") : t("push_disconnected")}
</span>
</span>
)}
</div>
</div>
</div>
);
}
}
+3 -1
View File
@@ -62,7 +62,9 @@ export function VacationSettings() {
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'));
}
+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>
);
}
+42 -14
View File
@@ -6,6 +6,11 @@ import { cn } from "@/lib/utils";
export type ToastType = "success" | "error" | "info" | "warning";
export interface ToastAction {
label: string;
onClick: () => void;
}
export interface Toast {
id: string;
type: ToastType;
@@ -14,6 +19,7 @@ export interface Toast {
duration?: number;
onClick?: () => void;
icon?: React.ReactNode;
action?: ToastAction;
}
interface ToastProps {
@@ -52,41 +58,63 @@ export function ToastItem({ toast, onClose }: ToastProps) {
className={cn(
"flex items-start gap-3 p-4 rounded-lg border shadow-lg bg-background animate-slide-in",
styles[toast.type],
toast.onClick && "cursor-pointer hover:opacity-90 transition-opacity"
toast.onClick && !toast.action && "cursor-pointer hover:opacity-90 transition-opacity"
)}
onClick={() => {
if (toast.onClick) {
if (toast.onClick && !toast.action) {
toast.onClick();
onClose(toast.id);
}
}}
>
{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>
{toast.message && (
<p className="text-sm mt-1 opacity-90">{toast.message}</p>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
onClose(toast.id);
}}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
<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
onClick={(e) => {
e.stopPropagation();
onClose(toast.id);
}}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}
export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
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) => (
<ToastItem key={toast.id} toast={toast} onClose={onClose} />
))}
</div>
);
}
}
+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 = {
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,
FORCE_BODY: true,
FORBID_TAGS: [
+88 -16
View File
@@ -12,7 +12,8 @@
"invalid_credentials": "Ungültige E-Mail-Adresse oder Passwort",
"connection_failed": "Verbindung zum Server fehlgeschlagen",
"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": {
"title": "Konfigurationsfehler",
@@ -23,7 +24,13 @@
"totp_toggle": "Ich habe Zwei-Faktor-Authentifizierung",
"totp_label": "Authentifizierungscode",
"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": {
"close": "Schließen",
@@ -66,7 +73,11 @@
"go_back": "Zurück"
},
"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": {
"no_emails": "Keine E-Mails",
@@ -202,7 +213,12 @@
"tooltips": {
"reply": "Antworten",
"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": {
"button_title": "Spam melden",
@@ -242,7 +258,8 @@
"no_calendar": "Kalender nicht verfügbar",
"select_calendar": "Kalender auswählen",
"already_in_calendar": "Bereits in deinem Kalender"
}
},
"keyboard_shortcuts": "Tastaturkürzel (?)"
},
"email_composer": {
"new_message": "Neue Nachricht",
@@ -290,7 +307,17 @@
},
"remove_sub_address": "Sub-Adresse entfernen",
"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": {
"loading": "Lädt...",
@@ -788,7 +815,12 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# Bedingung} other {# Bedingungen}}",
"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": {
"title": "E-Mail-Vorlagen",
@@ -822,7 +854,11 @@
"empty": "Vorlagenname ist erforderlich",
"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": {
"page_error_title": "Etwas ist schiefgelaufen",
@@ -910,7 +946,9 @@
"archive": "Unterhaltung archivieren",
"delete": "Unterhaltung löschen",
"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": {
"modal_title": "Sendeidentitäten verwalten",
@@ -1019,7 +1057,12 @@
"notes": "Notizen",
"no_contact_selected": "Kontakt auswählen, um Details anzuzeigen",
"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": {
"create_title": "Neuer Kontakt",
@@ -1044,7 +1087,8 @@
"updating": "Wird aktualisiert...",
"name_required": "Mindestens ein Vor- oder Nachname ist erforderlich",
"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": {
"create": "Neue Gruppe",
@@ -1102,7 +1146,12 @@
"error_create": "Kontakt konnte nicht erstellt werden",
"error_update": "Kontakt konnte nicht aktualisiert 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": {
"title": "Kalender",
@@ -1113,7 +1162,11 @@
"week": "Woche",
"day": "Tag",
"agenda": "Agenda",
"today": "Heute"
"today": "Heute",
"month_hint": "Monat (m)",
"week_hint": "Woche (w)",
"day_hint": "Tag (d)",
"agenda_hint": "Agenda (a)"
},
"events": {
"create": "Termin erstellen",
@@ -1159,7 +1212,10 @@
"you_organizer": "Sie sind der Organisator",
"you_attendee": "Sie sind ein 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": {
"title": "Wiederholung",
@@ -1249,7 +1305,8 @@
"error": "Kalender konnte nicht importiert werden",
"file_too_large": "Datei überschreitet das 5-MB-Limit",
"invalid_format": "Ungültiges Kalenderdateiformat"
}
},
"mini_calendar_change": "Klicken, um den Monat zu wechseln"
},
"advanced_search": {
"title": "Erweiterte Suche",
@@ -1272,6 +1329,21 @@
"clear_all": "Alle löschen",
"filters_active": "{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...",
"loading": "Loading...",
"error": {
"invalid_credentials": "Invalid email or password",
"connection_failed": "Failed to connect to the server",
"generic": "An error occurred. Please try again.",
"totp_invalid": "Invalid authentication code. Please check your authenticator app."
"invalid_credentials": "Invalid email or password. Please check your credentials and try again.",
"connection_failed": "Unable to reach the server. Check your internet connection and try again.",
"server_error": "The server is temporarily unavailable. Please try again later.",
"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": {
"title": "Configuration Error",
"fetch_failed": "Unable to load application configuration. Please try again later.",
@@ -23,12 +27,17 @@
"totp_toggle": "I have two-factor authentication",
"totp_label": "Authentication code",
"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": {
"close": "Close",
"compose": "Compose",
"compose_hint": "Compose (c)",
"search_placeholder": "Search mail...",
"search_placeholder_hint": "Search mail... (press /)",
"storage": "Storage",
"sign_out": "Sign out",
"contacts": "Contacts",
@@ -66,7 +75,9 @@
"go_back": "Go back"
},
"clear_search": "Clear search",
"vacation_active": "Vacation responder is active"
"vacation_active": "Vacation responder is active",
"mail": "Mail",
"nav_label": "Navigation"
},
"email_list": {
"no_emails": "No emails",
@@ -110,6 +121,7 @@
"mark_read": "Mark as read",
"print": "Print",
"view_source": "View source",
"keyboard_shortcuts": "Keyboard shortcuts (?)",
"email_source": "Email Source",
"copy_source": "Copy to clipboard",
"source_copied": "Source copied to clipboard",
@@ -200,9 +212,14 @@
"none": "None"
},
"tooltips": {
"reply": "Reply",
"archive": "Archive",
"delete": "Delete"
"reply": "Reply (r)",
"reply_all": "Reply All (a)",
"forward": "Forward (f)",
"archive": "Archive (e)",
"delete": "Delete (# or Del)",
"star": "Star (s)",
"unstar": "Unstar (s)",
"compose": "Compose (c)"
},
"spam": {
"button_title": "Report spam",
@@ -262,6 +279,7 @@
"cancel": "Cancel",
"attach": "Attach",
"discard": "Discard",
"discard_draft_title": "Discard draft?",
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
"saving": "Saving...",
"draft_saved": "Draft saved",
@@ -290,7 +308,20 @@
},
"remove_sub_address": "Remove sub-address",
"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": {
"loading": "Loading...",
@@ -393,6 +424,10 @@
"import_success": "Settings imported successfully",
"import_error": "Failed to import settings",
"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": {
"appearance": "Appearance",
"language": "Language & Region",
@@ -771,6 +806,11 @@
"validation_empty_name": "Rule name is required",
"validation_empty_conditions": "At least one condition with a value 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": {
"title": "Sieve Script Editor",
"warning": "Editing the raw Sieve script may break visual rule editing. Changes made here override the visual builder.",
@@ -902,6 +942,7 @@
"threads": {
"messages_one": "{count} message",
"messages_other": "{count} messages",
"messages_tooltip": "{count, plural, one {# message in this conversation} other {# messages in this conversation}}",
"expand": "Expand conversation",
"collapse": "Collapse conversation",
"loading": "Loading conversation...",
@@ -910,7 +951,8 @@
"archive": "Archive conversation",
"delete": "Delete conversation",
"star": "Star conversation",
"unstar": "Unstar conversation"
"unstar": "Unstar conversation",
"toggle_thread": "Toggle thread"
},
"identities": {
"modal_title": "Manage Sending Identities",
@@ -1003,7 +1045,12 @@
"search_placeholder": "Search contacts...",
"create_new": "New Contact",
"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_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?",
"local_mode": "Contacts are stored locally (server does not support JMAP Contacts)",
"back_to_mail": "Back to mail",
@@ -1018,6 +1065,11 @@
"addresses": "Addresses",
"notes": "Notes",
"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",
"updated": "Last updated"
},
@@ -1044,6 +1096,7 @@
"updating": "Updating...",
"name_required": "At least a first name or last name is required",
"email_invalid": "Please enter a valid email address",
"email_error_inline": "Invalid email format",
"save_failed": "Failed to save contact"
},
"groups": {
@@ -1107,13 +1160,18 @@
"calendar": {
"title": "Calendar",
"back_to_email": "Back to email",
"my_calendars": "My calendars",
"my_calendars": "Calendars",
"mini_calendar_change": "Click to change month",
"views": {
"month": "Month",
"week": "Week",
"day": "Day",
"agenda": "Agenda",
"today": "Today"
"today": "Today",
"month_hint": "Month (m)",
"week_hint": "Week (w)",
"day_hint": "Day (d)",
"agenda_hint": "Agenda (a)"
},
"events": {
"create": "Create event",
@@ -1155,6 +1213,9 @@
"email_placeholder": "Add email address or search contacts",
"send_invitations": "Send invitations to participants",
"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",
"you_organizer": "You are the organizer",
"you_attendee": "You are an attendee",
@@ -1272,6 +1333,17 @@
"clear_all": "Clear all",
"filters_active": "{count} filter",
"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",
"connection_failed": "No se pudo conectar con el servidor",
"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": {
"title": "Error de Configuración",
@@ -23,7 +24,13 @@
"totp_toggle": "Tengo autenticación de dos factores",
"totp_label": "Código de autenticación",
"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": {
"close": "Cerrar",
@@ -66,7 +73,11 @@
"go_back": "Volver"
},
"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": {
"no_emails": "Sin correos",
@@ -202,7 +213,12 @@
"tooltips": {
"reply": "Responder",
"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": {
"button_title": "Reportar spam",
@@ -242,7 +258,8 @@
"no_calendar": "Calendario no disponible",
"select_calendar": "Seleccionar calendario",
"already_in_calendar": "Ya está en tu calendario"
}
},
"keyboard_shortcuts": "Atajos de teclado (?)"
},
"email_composer": {
"new_message": "Nuevo Mensaje",
@@ -290,7 +307,17 @@
},
"remove_sub_address": "Eliminar sub-dirección",
"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": {
"loading": "Cargando...",
@@ -788,7 +815,12 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# condición} other {# condiciones}}",
"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": {
"title": "Plantillas de correo",
@@ -822,7 +854,11 @@
"empty": "El nombre de la plantilla es obligatorio",
"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": {
"page_error_title": "Algo salió mal",
@@ -910,7 +946,9 @@
"archive": "Archivar conversación",
"delete": "Eliminar 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": {
"modal_title": "Administrar Identidades de Envío",
@@ -1019,7 +1057,12 @@
"notes": "Notas",
"no_contact_selected": "Selecciona un contacto para ver los detalles",
"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": {
"create_title": "Nuevo contacto",
@@ -1044,7 +1087,8 @@
"updating": "Actualizando...",
"name_required": "Se requiere al menos un nombre o apellido",
"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": {
"create": "Nuevo grupo",
@@ -1102,7 +1146,12 @@
"error_create": "Error al crear el contacto",
"error_update": "Error al actualizar 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": {
"title": "Calendario",
@@ -1113,7 +1162,11 @@
"week": "Semana",
"day": "Día",
"agenda": "Agenda",
"today": "Hoy"
"today": "Hoy",
"month_hint": "Mes (m)",
"week_hint": "Semana (w)",
"day_hint": "Día (d)",
"agenda_hint": "Agenda (a)"
},
"events": {
"create": "Crear evento",
@@ -1159,7 +1212,10 @@
"you_organizer": "Eres el organizador",
"you_attendee": "Eres un participante",
"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": {
"title": "Recurrencia",
@@ -1249,7 +1305,8 @@
"error": "Error al importar el calendario",
"file_too_large": "El archivo supera el límite de 5 MB",
"invalid_format": "Formato de archivo de calendario no válido"
}
},
"mini_calendar_change": "Clic para cambiar de mes"
},
"advanced_search": {
"title": "Búsqueda avanzada",
@@ -1272,6 +1329,21 @@
"clear_all": "Limpiar todo",
"filters_active": "{count} filtro",
"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",
"connection_failed": "Échec de la connexion au serveur",
"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": {
"title": "Erreur de configuration",
@@ -23,12 +24,20 @@
"totp_toggle": "J'ai l'authentification à deux facteurs",
"totp_label": "Code d'authentification",
"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": {
"close": "Fermer",
"compose": "Composer",
"compose_hint": "Composer (c)",
"search_placeholder": "Rechercher un email...",
"search_placeholder_hint": "Rechercher... (appuyez sur /)",
"storage": "Stockage",
"sign_out": "Se déconnecter",
"contacts": "Contacts",
@@ -66,7 +75,9 @@
"go_back": "Retour"
},
"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": {
"no_emails": "Aucun email",
@@ -202,7 +213,12 @@
"tooltips": {
"reply": "Répondre",
"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": {
"button_title": "Signaler comme spam",
@@ -242,7 +258,8 @@
"no_calendar": "Calendrier non disponible",
"select_calendar": "Choisir un calendrier",
"already_in_calendar": "Déjà dans votre calendrier"
}
},
"keyboard_shortcuts": "Raccourcis clavier (?)"
},
"email_composer": {
"new_message": "Nouveau message",
@@ -290,7 +307,17 @@
},
"remove_sub_address": "Retirer le sous-adressage",
"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": {
"loading": "Chargement...",
@@ -788,7 +815,12 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# condition} other {# conditions}}",
"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": {
"title": "Modèles d'e-mails",
@@ -822,7 +854,11 @@
"empty": "Le nom du modèle est requis",
"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": {
"page_error_title": "Une erreur s'est produite",
@@ -910,7 +946,9 @@
"archive": "Archiver la conversation",
"delete": "Supprimer la conversation",
"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": {
"modal_title": "Gérer les identités d'envoi",
@@ -1019,7 +1057,12 @@
"notes": "Notes",
"no_contact_selected": "Sélectionnez un contact pour voir les détails",
"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": {
"create_title": "Nouveau contact",
@@ -1044,7 +1087,8 @@
"updating": "Mise à jour...",
"name_required": "Un prénom ou un nom est requis",
"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": {
"create": "Nouveau groupe",
@@ -1102,7 +1146,12 @@
"error_create": "Échec de la création du contact",
"error_update": "Échec de la mise à jour 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": {
"title": "Calendrier",
@@ -1113,7 +1162,11 @@
"week": "Semaine",
"day": "Jour",
"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": {
"create": "Créer un événement",
@@ -1159,7 +1212,10 @@
"you_organizer": "Vous êtes l'organisateur",
"you_attendee": "Vous êtes un 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": {
"title": "Récurrence",
@@ -1249,7 +1305,8 @@
"error": "Échec de l'importation du calendrier",
"file_too_large": "Le fichier dépasse la limite de 5 Mo",
"invalid_format": "Format de fichier calendrier invalide"
}
},
"mini_calendar_change": "Cliquer pour changer de mois"
},
"advanced_search": {
"title": "Recherche avancée",
@@ -1272,6 +1329,21 @@
"clear_all": "Tout effacer",
"filters_active": "{count} filtre",
"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",
"connection_failed": "Impossibile connettersi al server",
"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": {
"title": "Errore di configurazione",
@@ -23,7 +24,13 @@
"totp_toggle": "Ho l'autenticazione a due fattori",
"totp_label": "Codice di autenticazione",
"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": {
"close": "Chiudi",
@@ -66,7 +73,11 @@
"go_back": "Indietro"
},
"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": {
"no_emails": "Nessun messaggio",
@@ -202,7 +213,12 @@
"tooltips": {
"reply": "Rispondi",
"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": {
"button_title": "Segnala come spam",
@@ -242,7 +258,8 @@
"no_calendar": "Calendario non disponibile",
"select_calendar": "Seleziona calendario",
"already_in_calendar": "Già nel tuo calendario"
}
},
"keyboard_shortcuts": "Scorciatoie da tastiera (?)"
},
"email_composer": {
"new_message": "Nuovo messaggio",
@@ -290,7 +307,17 @@
},
"remove_sub_address": "Rimuovi sotto-indirizzo",
"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": {
"loading": "Caricamento...",
@@ -788,7 +815,12 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# condizione} other {# condizioni}}",
"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": {
"title": "Modelli email",
@@ -822,7 +854,11 @@
"empty": "Il nome del modello è obbligatorio",
"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": {
"page_error_title": "Qualcosa è andato storto",
@@ -910,7 +946,9 @@
"archive": "Archivia conversazione",
"delete": "Elimina 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": {
"modal_title": "Gestisci identità di invio",
@@ -1019,7 +1057,12 @@
"notes": "Note",
"no_contact_selected": "Seleziona un contatto per vedere i dettagli",
"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": {
"create_title": "Nuovo contatto",
@@ -1044,7 +1087,8 @@
"updating": "Aggiornamento...",
"name_required": "È richiesto almeno un nome o cognome",
"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": {
"create": "Nuovo gruppo",
@@ -1102,7 +1146,12 @@
"error_create": "Impossibile creare il contatto",
"error_update": "Impossibile aggiornare 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": {
"title": "Calendario",
@@ -1113,7 +1162,11 @@
"week": "Settimana",
"day": "Giorno",
"agenda": "Agenda",
"today": "Oggi"
"today": "Oggi",
"month_hint": "Mese (m)",
"week_hint": "Settimana (w)",
"day_hint": "Giorno (d)",
"agenda_hint": "Agenda (a)"
},
"events": {
"create": "Crea evento",
@@ -1159,7 +1212,10 @@
"you_organizer": "Sei l'organizzatore",
"you_attendee": "Sei un 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": {
"title": "Ricorrenza",
@@ -1249,7 +1305,8 @@
"error": "Importazione del calendario fallita",
"file_too_large": "Il file supera il limite di 5 MB",
"invalid_format": "Formato del file calendario non valido"
}
},
"mini_calendar_change": "Clicca per cambiare mese"
},
"advanced_search": {
"title": "Ricerca avanzata",
@@ -1272,6 +1329,21 @@
"clear_all": "Cancella tutto",
"filters_active": "{count} filtro",
"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": "メールアドレスまたはパスワードが無効です",
"connection_failed": "サーバーへの接続に失敗しました",
"generic": "エラーが発生しました。もう一度お試しください。",
"totp_invalid": "認証コードが無効です。認証アプリを確認してください。"
"totp_invalid": "認証コードが無効です。認証アプリを確認してください。",
"server_error": "サーバーが一時的に利用できません。後でもう一度お試しください。"
},
"config_error": {
"title": "設定エラー",
@@ -23,12 +24,20 @@
"totp_toggle": "二要素認証を使用",
"totp_label": "認証コード",
"totp_placeholder": "000000",
"totp_hide": "二要素認証を非表示"
"totp_hide": "二要素認証を非表示",
"totp_checkbox": "二要素認証コードを使用する",
"session_expired": "セッションが期限切れになりました。再度サインインしてください。",
"dismiss": "閉じる",
"show_password": "パスワードを表示",
"hide_password": "パスワードを隠す",
"totp_hint": "アカウントに二要素認証コードが必要な場合に有効にしてください"
},
"sidebar": {
"close": "閉じる",
"compose": "作成",
"compose_hint": "作成 (c)",
"search_placeholder": "メールを検索...",
"search_placeholder_hint": "メールを検索... (/ を押す)",
"storage": "ストレージ",
"sign_out": "サインアウト",
"contacts": "連絡先",
@@ -66,7 +75,9 @@
"go_back": "戻る"
},
"clear_search": "検索をクリア",
"vacation_active": "不在応答が有効です"
"vacation_active": "不在応答が有効です",
"mail": "メール",
"nav_label": "ナビゲーション"
},
"email_list": {
"no_emails": "メールがありません",
@@ -202,7 +213,12 @@
"tooltips": {
"reply": "返信",
"archive": "アーカイブ",
"delete": "削除"
"delete": "削除",
"reply_all": "全員に返信 (a)",
"forward": "転送 (f)",
"star": "スター (s)",
"unstar": "スター解除 (s)",
"compose": "新規作成 (c)"
},
"spam": {
"button_title": "迷惑メールを報告",
@@ -242,7 +258,8 @@
"no_calendar": "カレンダーが利用できません",
"select_calendar": "カレンダーを選択",
"already_in_calendar": "カレンダーに登録済み"
}
},
"keyboard_shortcuts": "キーボードショートカット (?)"
},
"email_composer": {
"new_message": "新規メッセージ",
@@ -290,7 +307,17 @@
},
"remove_sub_address": "サブアドレスを削除",
"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": {
"loading": "読み込み中...",
@@ -788,7 +815,12 @@
"rule_summary": {
"conditions_count": "{count, plural, other {#個の条件}}",
"actions_count": "{count, plural, other {#個のアクション}}"
}
},
"templates_section": "テンプレートから作成",
"template_newsletters": "ニュースレターをフォルダに移動",
"template_receipts": "領収書を自動アーカイブ",
"template_important": "重要なメールにフラグを付ける",
"template_notifications": "通知をフィルター"
},
"templates": {
"title": "メールテンプレート",
@@ -822,7 +854,11 @@
"empty": "テンプレート名は必須です",
"too_long": "テンプレート名は200文字以内にしてください"
}
}
},
"unsaved_changes": "保存されていない変更があります",
"discard_changes": "変更を破棄しますか?",
"discard": "破棄",
"keep_editing": "編集を続ける"
},
"errors": {
"page_error_title": "問題が発生しました",
@@ -910,7 +946,9 @@
"archive": "会話をアーカイブ",
"delete": "会話を削除",
"star": "会話にスターを付ける",
"unstar": "会話のスターを外す"
"unstar": "会話のスターを外す",
"messages_tooltip": "{count, plural, other {この会話の#件のメッセージ}}",
"toggle_thread": "スレッドの展開/折りたたみ"
},
"identities": {
"modal_title": "送信者情報を管理",
@@ -1019,7 +1057,12 @@
"notes": "メモ",
"no_contact_selected": "連絡先を選択して詳細を表示",
"created": "作成日",
"updated": "最終更新"
"updated": "最終更新",
"compose_email": "メールを作成",
"copy_email": "メールをコピー",
"copy_phone": "電話番号をコピー",
"copied": "クリップボードにコピーしました",
"copy_failed": "クリップボードへのコピーに失敗しました"
},
"form": {
"create_title": "新しい連絡先",
@@ -1044,7 +1087,8 @@
"updating": "更新中...",
"name_required": "名前は必須です",
"email_invalid": "有効なメールアドレスを入力してください",
"save_failed": "連絡先の保存に失敗しました"
"save_failed": "連絡先の保存に失敗しました",
"email_error_inline": "メールアドレスの形式が正しくありません"
},
"groups": {
"create": "新しいグループ",
@@ -1102,7 +1146,12 @@
"error_create": "連絡先の作成に失敗しました",
"error_update": "連絡先の更新に失敗しました",
"error_delete": "連絡先の削除に失敗しました"
}
},
"empty_state_title": "連絡先がありません",
"empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください",
"empty_search_hint": "別の検索語をお試しください",
"clear_search": "検索をクリア",
"import_vcard": "vCardをインポート"
},
"calendar": {
"title": "カレンダー",
@@ -1113,7 +1162,11 @@
"week": "週",
"day": "日",
"agenda": "予定リスト",
"today": "今日"
"today": "今日",
"month_hint": "月 (m)",
"week_hint": "週 (w)",
"day_hint": "日 (d)",
"agenda_hint": "予定一覧 (a)"
},
"events": {
"create": "予定を作成",
@@ -1159,7 +1212,10 @@
"you_organizer": "あなたは主催者です",
"you_attendee": "あなたは参加者です",
"no_participants": "参加者なし",
"count": "{count}人の参加者"
"count": "{count}人の参加者",
"invited_by": "{name}からの招待",
"respond_below": "以下のボタンで返答してください",
"rsvp_label": "あなたの返答"
},
"recurrence": {
"title": "繰り返し",
@@ -1249,7 +1305,8 @@
"error": "カレンダーのインポートに失敗しました",
"file_too_large": "ファイルサイズが5MBを超えています",
"invalid_format": "無効なカレンダーファイル形式"
}
},
"mini_calendar_change": "クリックで月を変更"
},
"advanced_search": {
"title": "詳細検索",
@@ -1272,6 +1329,21 @@
"clear_all": "すべてクリア",
"filters_active": "{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",
"connection_failed": "Kan geen verbinding maken met de server",
"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": {
"title": "Configuratiefout",
@@ -23,7 +24,13 @@
"totp_toggle": "Ik heb tweefactorauthenticatie",
"totp_label": "Authenticatiecode",
"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": {
"close": "Sluiten",
@@ -66,7 +73,11 @@
"go_back": "Terug"
},
"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": {
"no_emails": "Geen e-mails",
@@ -202,7 +213,12 @@
"tooltips": {
"reply": "Beantwoorden",
"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": {
"button_title": "Spam melden",
@@ -242,7 +258,8 @@
"no_calendar": "Agenda niet beschikbaar",
"select_calendar": "Agenda selecteren",
"already_in_calendar": "Staat al in je agenda"
}
},
"keyboard_shortcuts": "Sneltoetsen (?)"
},
"email_composer": {
"new_message": "Nieuw bericht",
@@ -290,7 +307,17 @@
},
"remove_sub_address": "Sub-adres verwijderen",
"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": {
"loading": "Laden...",
@@ -788,7 +815,12 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# voorwaarde} other {# voorwaarden}}",
"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": {
"title": "E-mailsjablonen",
@@ -822,7 +854,11 @@
"empty": "Sjabloonnaam is verplicht",
"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": {
"page_error_title": "Er is iets misgegaan",
@@ -910,7 +946,9 @@
"archive": "Gesprek archiveren",
"delete": "Gesprek verwijderen",
"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": {
"modal_title": "Verzendidentiteiten beheren",
@@ -1019,7 +1057,12 @@
"notes": "Notities",
"no_contact_selected": "Selecteer een contact om details te bekijken",
"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": {
"create_title": "Nieuw contact",
@@ -1044,7 +1087,8 @@
"updating": "Bijwerken...",
"name_required": "Ten minste een voor- of achternaam is vereist",
"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": {
"create": "Nieuwe groep",
@@ -1102,7 +1146,12 @@
"error_create": "Kon contact niet aanmaken",
"error_update": "Kon contact niet bijwerken",
"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": {
"title": "Agenda",
@@ -1113,7 +1162,11 @@
"week": "Week",
"day": "Dag",
"agenda": "Agenda",
"today": "Vandaag"
"today": "Vandaag",
"month_hint": "Maand (m)",
"week_hint": "Week (w)",
"day_hint": "Dag (d)",
"agenda_hint": "Agenda (a)"
},
"events": {
"create": "Evenement aanmaken",
@@ -1159,7 +1212,10 @@
"you_organizer": "Je bent de organisator",
"you_attendee": "Je bent een deelnemer",
"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": {
"title": "Herhaling",
@@ -1249,7 +1305,8 @@
"error": "Agenda importeren mislukt",
"file_too_large": "Bestand overschrijdt de limiet van 5 MB",
"invalid_format": "Ongeldig agendabestandsformaat"
}
},
"mini_calendar_change": "Klik om van maand te wisselen"
},
"advanced_search": {
"title": "Geavanceerd zoeken",
@@ -1272,6 +1329,21 @@
"clear_all": "Alles wissen",
"filters_active": "{count} filter",
"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",
"connection_failed": "Falha ao conectar com o servidor",
"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": {
"title": "Erro de Configuração",
@@ -23,7 +24,13 @@
"totp_toggle": "Tenho autenticação de dois fatores",
"totp_label": "Código de autenticação",
"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": {
"close": "Fechar",
@@ -66,7 +73,11 @@
"go_back": "Voltar"
},
"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": {
"no_emails": "Nenhum e-mail",
@@ -202,7 +213,12 @@
"tooltips": {
"reply": "Responder",
"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": {
"button_title": "Reportar spam",
@@ -242,7 +258,8 @@
"no_calendar": "Calendário não disponível",
"select_calendar": "Selecionar calendário",
"already_in_calendar": "Já está no seu calendário"
}
},
"keyboard_shortcuts": "Atalhos de teclado (?)"
},
"email_composer": {
"new_message": "Nova Mensagem",
@@ -290,7 +307,17 @@
},
"remove_sub_address": "Remover sub-endereço",
"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": {
"loading": "Carregando...",
@@ -788,7 +815,12 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# condição} other {# condiçõ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": {
"title": "Modelos de e-mail",
@@ -822,7 +854,11 @@
"empty": "O nome do modelo é obrigatório",
"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": {
"page_error_title": "Algo deu errado",
@@ -910,7 +946,9 @@
"archive": "Arquivar conversa",
"delete": "Excluir 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": {
"modal_title": "Gerenciar Identidades de Envio",
@@ -1019,7 +1057,12 @@
"notes": "Notas",
"no_contact_selected": "Selecione um contato para ver os detalhes",
"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": {
"create_title": "Novo contato",
@@ -1044,7 +1087,8 @@
"updating": "Atualizando...",
"name_required": "É necessário pelo menos um nome ou sobrenome",
"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": {
"create": "Novo grupo",
@@ -1102,7 +1146,12 @@
"error_create": "Falha ao criar contato",
"error_update": "Falha ao atualizar 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": {
"title": "Calendário",
@@ -1113,7 +1162,11 @@
"week": "Semana",
"day": "Dia",
"agenda": "Agenda",
"today": "Hoje"
"today": "Hoje",
"month_hint": "Mês (m)",
"week_hint": "Semana (w)",
"day_hint": "Dia (d)",
"agenda_hint": "Agenda (a)"
},
"events": {
"create": "Criar evento",
@@ -1159,7 +1212,10 @@
"you_organizer": "Você é o organizador",
"you_attendee": "Você é um participante",
"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": {
"title": "Recorrência",
@@ -1249,7 +1305,8 @@
"error": "Falha ao importar calendário",
"file_too_large": "Arquivo excede o limite de 5 MB",
"invalid_format": "Formato de arquivo de calendário inválido"
}
},
"mini_calendar_change": "Clique para mudar o mês"
},
"advanced_search": {
"title": "Pesquisa avançada",
@@ -1272,6 +1329,21 @@
"clear_all": "Limpar tudo",
"filters_active": "{count} filtro",
"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 });
try {
// Create JMAP client
const client = new JMAPClient(serverUrl, username, effectivePassword);
// Try to connect
await client.connect();
// Fetch identities from the server
const identities = await client.getIdentities();
const primaryIdentity = identities.length > 0 ? identities[0] : null;
// Sync identities to identity store
useIdentityStore.getState().setIdentities(identities);
// Fetch contacts if server supports JMAP Contacts
@@ -66,7 +60,6 @@ export const useAuthStore = create<AuthState>()(
useContactStore.getState().setSupportsSync(false);
}
// Initialize vacation responder if supported
const vacationStore = useVacationStore.getState();
if (client.supportsVacationResponse()) {
vacationStore.setSupported(true);
@@ -75,14 +68,12 @@ export const useAuthStore = create<AuthState>()(
vacationStore.setSupported(false);
}
// Initialize calendar if supported
if (client.supportsCalendars()) {
const calendarStore = useCalendarStore.getState();
calendarStore.setSupported(true);
calendarStore.fetchCalendars(client).catch((err) => console.error('Failed to fetch calendars:', err));
}
// Initialize Sieve filters if supported
if (client.supportsSieve()) {
const filterStore = useFilterStore.getState();
filterStore.setSupported(true);
@@ -106,15 +97,23 @@ export const useAuthStore = create<AuthState>()(
debug.error('Login error:', error);
let errorKey = 'generic';
// Map common errors to translation keys
if (error instanceof Error) {
if (error.message.includes('Invalid username or password') ||
error.message.includes('401') ||
error.message.includes('Unauthorized')) {
errorKey = 'invalid_credentials';
} else if (error.message.includes('network') ||
error.message.includes('Failed to fetch')) {
error.message.includes('Failed to fetch') ||
error.message.includes('NetworkError') ||
error.message.includes('ECONNREFUSED')) {
errorKey = 'connection_failed';
} else if (error.message.includes('500') ||
error.message.includes('502') ||
error.message.includes('503') ||
error.message.includes('504') ||
error.message.includes('Internal Server Error') ||
error.message.includes('Service Unavailable')) {
errorKey = 'server_error';
}
}
@@ -131,7 +130,6 @@ export const useAuthStore = create<AuthState>()(
logout: () => {
const state = get();
// Disconnect the JMAP client if it exists
if (state.client) {
state.client.disconnect();
}
@@ -146,10 +144,8 @@ export const useAuthStore = create<AuthState>()(
error: null,
});
// Clear persisted storage
localStorage.removeItem('auth-storage');
// Clear email store state
useEmailStore.setState({
emails: [],
mailboxes: [],
@@ -161,29 +157,21 @@ export const useAuthStore = create<AuthState>()(
quota: null,
});
// Clear identity store state
useIdentityStore.getState().clearIdentities();
// Clear contact store state
useContactStore.getState().clearContacts();
// Clear vacation store state
useVacationStore.getState().clearState();
// Clear calendar store state
useCalendarStore.getState().clearState();
// Clear filter store state
useFilterStore.getState().clearState();
},
checkAuth: async () => {
const state = get();
// If authenticated but no client (e.g., after page refresh), we can't restore the session
// because we don't store passwords for security reasons
if (state.isAuthenticated && !state.client) {
// Reset auth state - user will need to log in again
try {
sessionStorage.setItem('session_expired', 'true');
} catch { /* sessionStorage unavailable */ }
set({
isAuthenticated: false,
isLoading: false,
@@ -193,7 +181,6 @@ export const useAuthStore = create<AuthState>()(
});
}
// Mark loading as complete
set({ isLoading: false });
},
+24 -16
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { Toast } from "@/components/ui/toast";
import { Toast, ToastAction } from "@/components/ui/toast";
interface ToastStore {
toasts: Toast[];
@@ -16,7 +16,7 @@ export const useToastStore = create<ToastStore>((set) => ({
const newToast: Toast = {
...toast,
id,
duration: toast.duration ?? 5000, // Default 5 seconds
duration: toast.duration ?? 5000,
};
set((state) => ({
@@ -35,18 +35,26 @@ export const useToastStore = create<ToastStore>((set) => ({
},
}));
// Helper functions for common toast types
interface ToastOptions {
message?: string;
action?: ToastAction;
duration?: number;
}
function showToast(type: Toast["type"], title: string, options?: string | ToastOptions, defaultDuration?: number): void {
const opts = typeof options === "string" ? { message: options } : options;
useToastStore.getState().addToast({
type,
title,
message: opts?.message,
action: opts?.action,
duration: opts?.duration ?? defaultDuration,
});
}
export const toast = {
success: (title: string, message?: string) => {
useToastStore.getState().addToast({ type: "success", title, message });
},
error: (title: string, message?: string) => {
useToastStore.getState().addToast({ type: "error", title, message, duration: 10000 });
},
info: (title: string, message?: string) => {
useToastStore.getState().addToast({ type: "info", title, message });
},
warning: (title: string, message?: string) => {
useToastStore.getState().addToast({ type: "warning", title, message });
},
};
success: (title: string, options?: string | ToastOptions) => showToast("success", title, options),
error: (title: string, options?: string | ToastOptions) => showToast("error", title, options, 10000),
info: (title: string, options?: string | ToastOptions) => showToast("info", title, options),
warning: (title: string, options?: string | ToastOptions) => showToast("warning", title, options),
};