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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -23,6 +23,7 @@ import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
||||
import { EventModal } from "@/components/calendar/event-modal";
|
||||
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||
|
||||
export default function CalendarPage() {
|
||||
@@ -308,40 +309,53 @@ export default function CalendarPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
<CalendarToolbar
|
||||
selectedDate={selectedDate}
|
||||
viewMode={viewMode}
|
||||
onNavigateBack={() => router.push("/")}
|
||||
onPrev={navigatePrev}
|
||||
onNext={navigateNext}
|
||||
onToday={goToToday}
|
||||
onViewModeChange={setViewMode}
|
||||
onCreateEvent={() => openCreateModal()}
|
||||
onImport={() => setShowImportModal(true)}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
<div className="flex h-screen bg-background">
|
||||
{/* Left Navigation Rail */}
|
||||
{!isMobile && (
|
||||
<div className="w-14 border-r border-border bg-secondary flex flex-col items-center py-3 flex-shrink-0">
|
||||
<NavigationRail collapsed className="py-0" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{!isMobile && (
|
||||
<div className="w-60 border-r border-border p-3 overflow-y-auto flex-shrink-0">
|
||||
<MiniCalendar
|
||||
selectedDate={selectedDate}
|
||||
displayMonth={miniMonth}
|
||||
onSelectDate={handleSelectDate}
|
||||
onChangeMonth={handleMiniMonthChange}
|
||||
events={events}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
/>
|
||||
<CalendarSidebarPanel
|
||||
calendars={calendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
onToggleVisibility={toggleCalendarVisibility}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<CalendarToolbar
|
||||
selectedDate={selectedDate}
|
||||
viewMode={viewMode}
|
||||
onPrev={navigatePrev}
|
||||
onNext={navigateNext}
|
||||
onToday={goToToday}
|
||||
onViewModeChange={setViewMode}
|
||||
onCreateEvent={() => openCreateModal()}
|
||||
onImport={() => setShowImportModal(true)}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{!isMobile && (
|
||||
<div className="w-60 border-r border-border p-3 overflow-y-auto flex-shrink-0">
|
||||
<MiniCalendar
|
||||
selectedDate={selectedDate}
|
||||
displayMonth={miniMonth}
|
||||
onSelectDate={handleSelectDate}
|
||||
onChangeMonth={handleMiniMonthChange}
|
||||
events={events}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
/>
|
||||
<CalendarSidebarPanel
|
||||
calendars={calendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
onToggleVisibility={toggleCalendarVisibility}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderView()}
|
||||
</div>
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
{isMobile && (
|
||||
<NavigationRail orientation="horizontal" />
|
||||
)}
|
||||
|
||||
{renderView()}
|
||||
</div>
|
||||
|
||||
{showEventModal && (
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowLeft, Upload, Download, Users, BookUser } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { ContactList } from "@/components/contacts/contact-list";
|
||||
import { ContactDetail } from "@/components/contacts/contact-detail";
|
||||
import { ContactForm } from "@/components/contacts/contact-form";
|
||||
@@ -17,6 +19,8 @@ import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
|
||||
type View =
|
||||
@@ -68,6 +72,8 @@ export default function ContactsPage() {
|
||||
const [view, setView] = useState<View>("list");
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const hasFetched = useRef(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
@@ -105,7 +111,14 @@ export default function ContactsPage() {
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedContact) return;
|
||||
if (!window.confirm(t("delete_confirm"))) return;
|
||||
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("delete_confirm_title"),
|
||||
message: t("delete_confirm"),
|
||||
confirmText: t("form.delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
if (supportsSync && client) {
|
||||
@@ -178,7 +191,14 @@ export default function ContactsPage() {
|
||||
|
||||
const handleDeleteGroup = async () => {
|
||||
if (!selectedGroup) return;
|
||||
if (!window.confirm(t("groups.delete_confirm"))) return;
|
||||
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("groups.delete_confirm_title"),
|
||||
message: t("groups.delete_confirm"),
|
||||
confirmText: t("form.delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await deleteGroup(supportsSync && client ? client : null, selectedGroup.id);
|
||||
@@ -228,7 +248,14 @@ export default function ContactsPage() {
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedContactIds.size === 0) return;
|
||||
if (!window.confirm(t("bulk.delete_confirm", { count: selectedContactIds.size }))) return;
|
||||
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("bulk.delete_confirm_title"),
|
||||
message: t("bulk.delete_confirm", { count: selectedContactIds.size }),
|
||||
confirmText: t("bulk.delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await bulkDeleteContacts(
|
||||
@@ -402,7 +429,15 @@ export default function ContactsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<div className="w-80 border-r border-border flex flex-col">
|
||||
{!isMobile && (
|
||||
<div className="w-14 border-r border-border bg-secondary flex flex-col items-center flex-shrink-0">
|
||||
<NavigationRail collapsed />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="w-80 border-r border-border flex flex-col flex-shrink-0">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
@@ -482,6 +517,7 @@ export default function ContactsPage() {
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelectContact={handleSelectContact}
|
||||
onCreateNew={handleCreateNew}
|
||||
onImport={() => setView("import")}
|
||||
supportsSync={supportsSync}
|
||||
className="flex-1"
|
||||
selectedContactIds={selectedContactIds}
|
||||
@@ -504,9 +540,17 @@ export default function ContactsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{renderRightPanel()}
|
||||
<div className="flex-1 min-w-0">
|
||||
{renderRightPanel()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<NavigationRail orientation="horizontal" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+146
-57
@@ -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"
|
||||
|
||||
+28
-2
@@ -30,6 +30,8 @@ import {
|
||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||
import { AdvancedSearchPanel } from "@/components/search/advanced-search-panel";
|
||||
import { isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
@@ -37,6 +39,7 @@ export default function Home() {
|
||||
const tCommon = useTranslations('common');
|
||||
const [showComposer, setShowComposer] = useState(false);
|
||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||
const [composerDraftText, setComposerDraftText] = useState("");
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(false);
|
||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||
// Mobile conversation view state
|
||||
@@ -395,7 +398,8 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = () => {
|
||||
const handleReply = (draftText?: string) => {
|
||||
setComposerDraftText(draftText || "");
|
||||
setComposerMode('reply');
|
||||
setShowComposer(true);
|
||||
};
|
||||
@@ -718,6 +722,13 @@ export default function Home() {
|
||||
return (
|
||||
<DragDropProvider>
|
||||
<div className="flex h-screen bg-background overflow-hidden">
|
||||
{/* Desktop Navigation Rail */}
|
||||
{!isMobile && !isTablet && (
|
||||
<div className="w-14 border-r border-border bg-secondary flex flex-col items-center flex-shrink-0">
|
||||
<NavigationRail collapsed />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile/Tablet Sidebar Overlay Backdrop */}
|
||||
{(isMobile || isTablet) && sidebarOpen && (
|
||||
<div
|
||||
@@ -760,7 +771,8 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex flex-1 min-w-0 h-full">
|
||||
<div className="flex flex-col flex-1 min-w-0 h-full">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Email List - full width on mobile, fixed width on tablet/desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -796,6 +808,8 @@ export default function Home() {
|
||||
onClose={toggleAdvancedSearch}
|
||||
/>
|
||||
|
||||
<WelcomeBanner />
|
||||
|
||||
<ErrorBoundary fallback={EmailListErrorFallback}>
|
||||
<EmailList
|
||||
emails={emails}
|
||||
@@ -917,6 +931,7 @@ export default function Home() {
|
||||
setTabletListVisible(true);
|
||||
selectEmail(null);
|
||||
}}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
currentUserEmail={client?.["username"]}
|
||||
currentUserName={client?.["username"]?.split("@")[0]}
|
||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||
@@ -926,6 +941,12 @@ export default function Home() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
{isMobile && activeView !== "viewer" && (
|
||||
<NavigationRail orientation="horizontal" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Composer Modal */}
|
||||
@@ -952,10 +973,12 @@ export default function Home() {
|
||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||
receivedAt: selectedEmail.receivedAt
|
||||
} : undefined}
|
||||
initialDraftText={composerDraftText}
|
||||
onSend={handleEmailSend}
|
||||
onClose={() => {
|
||||
setShowComposer(false);
|
||||
setComposerMode('compose');
|
||||
setComposerDraftText("");
|
||||
}}
|
||||
onDiscardDraft={handleDiscardDraft}
|
||||
/>
|
||||
@@ -969,6 +992,9 @@ export default function Home() {
|
||||
isOpen={showShortcutsModal}
|
||||
onClose={() => setShowShortcutsModal(false)}
|
||||
/>
|
||||
|
||||
{/* Screen reader live region for dynamic status announcements */}
|
||||
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
);
|
||||
|
||||
@@ -296,6 +296,7 @@ body {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
|
||||
/* Mobile Responsive Utilities */
|
||||
|
||||
/* Safe area insets for notched devices (iPhone X+, etc.) */
|
||||
@@ -385,3 +386,23 @@ body {
|
||||
.animate-slide-in-from-left {
|
||||
animation: slide-in-from-left 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Reduced motion: respect user OS preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Disable backdrop-filter on mobile for performance */
|
||||
@media (max-width: 767px) {
|
||||
.mobile-backdrop {
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Plus, Upload } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Plus, Upload } from "lucide-react";
|
||||
import { addDays, startOfWeek } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||
@@ -10,7 +10,6 @@ import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||
interface CalendarToolbarProps {
|
||||
selectedDate: Date;
|
||||
viewMode: CalendarViewMode;
|
||||
onNavigateBack: () => void;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onToday: () => void;
|
||||
@@ -19,12 +18,12 @@ interface CalendarToolbarProps {
|
||||
onImport?: () => void;
|
||||
isMobile?: boolean;
|
||||
firstDayOfWeek?: number;
|
||||
onNavigateBack?: () => void;
|
||||
}
|
||||
|
||||
export function CalendarToolbar({
|
||||
selectedDate,
|
||||
viewMode,
|
||||
onNavigateBack,
|
||||
onPrev,
|
||||
onNext,
|
||||
onToday,
|
||||
@@ -60,11 +59,6 @@ export function CalendarToolbar({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border flex-wrap">
|
||||
<Button variant="ghost" size="sm" onClick={onNavigateBack} className="mr-1">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||
{!isMobile && t("back_to_email")}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={onPrev} className="p-1.5 rounded hover:bg-muted transition-colors" aria-label={t("nav_prev")}>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
@@ -89,6 +83,7 @@ export function CalendarToolbar({
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => onViewModeChange(v)}
|
||||
title={t(`views.${v}_hint`)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
v === viewMode
|
||||
|
||||
@@ -41,17 +41,17 @@ function parseDuration(duration: string): number {
|
||||
return totalMinutes;
|
||||
}
|
||||
|
||||
function createEventDragPreview(title: string, color: string): HTMLElement {
|
||||
function createEventDragPreview(title: string, timeRange: string, color: string): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
el.style.cssText = `
|
||||
position: fixed; top: -9999px; left: 0;
|
||||
padding: 6px 12px; border-radius: 6px;
|
||||
background: ${color}40; border-left: 3px solid ${color};
|
||||
color: ${color}; font-size: 12px; font-weight: 500;
|
||||
max-width: 200px; white-space: nowrap; overflow: hidden;
|
||||
max-width: 240px; white-space: nowrap; overflow: hidden;
|
||||
text-overflow: ellipsis; pointer-events: none; z-index: 9999;
|
||||
`;
|
||||
el.textContent = title;
|
||||
el.textContent = `${title} \u2022 ${timeRange}`;
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
|
||||
}));
|
||||
const displayTitle = event.title || t("events.no_title");
|
||||
e.dataTransfer.setData("text/plain", displayTitle);
|
||||
const preview = createEventDragPreview(displayTitle, color);
|
||||
const preview = createEventDragPreview(displayTitle, timeString, color);
|
||||
e.dataTransfer.setDragImage(preview, 0, 0);
|
||||
requestAnimationFrame(() => preview.remove());
|
||||
setIsBeingDragged(true);
|
||||
@@ -135,13 +135,16 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
|
||||
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color }}
|
||||
>
|
||||
<div className="font-medium truncate">{event.title || t("events.no_title")}</div>
|
||||
{durationMinutes > 30 && (
|
||||
{!event.showWithoutTime && (
|
||||
<div className="opacity-80 text-[10px]">
|
||||
{timeString}
|
||||
</div>
|
||||
)}
|
||||
{durationMinutes > 30 && getParticipantCount(event) > 0 && (
|
||||
<div className="flex items-center gap-0.5 opacity-70 text-[10px]">
|
||||
{getParticipantCount(event) > 0 && (
|
||||
<div
|
||||
className="flex items-center gap-0.5 opacity-70 text-[10px]"
|
||||
title={t("participants.count", { count: getParticipantCount(event) })}
|
||||
>
|
||||
<Users className="w-3 h-3" />
|
||||
<span>{getParticipantCount(event)}</span>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2, Check, HelpCircle, XCircle, Users } from "lucide-react";
|
||||
import { X, Trash2, Check, Users, CalendarDays } from "lucide-react";
|
||||
import { format, parseISO, addHours } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { parseDuration } from "./event-card";
|
||||
@@ -94,6 +94,12 @@ export function EventModal({
|
||||
return getParticipantList(event);
|
||||
}, [event]);
|
||||
|
||||
const organizerInfo = useMemo(() => {
|
||||
if (!event?.participants) return null;
|
||||
const organizer = existingParticipants.find(p => p.isOrganizer);
|
||||
return organizer ? { name: organizer.name, email: organizer.email } : null;
|
||||
}, [event, existingParticipants]);
|
||||
|
||||
const getInitialStart = (): Date => {
|
||||
if (event?.start) return parseISO(event.start);
|
||||
if (defaultDate) {
|
||||
@@ -342,6 +348,18 @@ export function EventModal({
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3">
|
||||
<CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-blue-900 dark:text-blue-200">
|
||||
{t("participants.invited_by", { name: organizerInfo?.name || organizerInfo?.email || t("participants.organizer") })}
|
||||
</p>
|
||||
<p className="text-blue-700 dark:text-blue-400 mt-0.5">
|
||||
{t("participants.respond_below")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span>
|
||||
{!event.showWithoutTime && (
|
||||
@@ -359,10 +377,6 @@ export function EventModal({
|
||||
<p className="text-sm text-muted-foreground">{locationName}</p>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("participants.you_attendee")}
|
||||
</div>
|
||||
|
||||
{participants.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium">
|
||||
@@ -383,33 +397,39 @@ export function EventModal({
|
||||
|
||||
<div className="px-5 py-4 border-t border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">RSVP</span>
|
||||
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
||||
onClick={() => handleRsvp("accepted")}
|
||||
className={userCurrentStatus === "accepted" ? "bg-green-600 hover:bg-green-700 text-white ring-2 ring-green-300 dark:ring-green-700" : "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"}
|
||||
className={userCurrentStatus === "accepted"
|
||||
? "bg-green-600 hover:bg-green-700 text-white dark:bg-green-500 dark:hover:bg-green-600"
|
||||
: "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"}
|
||||
>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
|
||||
{t("participants.accepted")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
||||
onClick={() => handleRsvp("tentative")}
|
||||
className={userCurrentStatus === "tentative" ? "bg-amber-600 hover:bg-amber-700 text-white ring-2 ring-amber-300 dark:ring-amber-700" : "text-amber-600 dark:text-amber-400 border-amber-300 dark:border-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950"}
|
||||
className={userCurrentStatus === "tentative"
|
||||
? "bg-amber-600 hover:bg-amber-700 text-white dark:bg-amber-500 dark:hover:bg-amber-600"
|
||||
: "border border-amber-500 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950"}
|
||||
>
|
||||
<HelpCircle className="w-4 h-4 mr-1" />
|
||||
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
|
||||
{t("participants.tentative")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "declined" ? "default" : "outline"}
|
||||
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
|
||||
onClick={() => handleRsvp("declined")}
|
||||
className={userCurrentStatus === "declined" ? "bg-red-600 hover:bg-red-700 text-white ring-2 ring-red-300 dark:ring-red-700" : "text-red-600 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-50 dark:hover:bg-red-950"}
|
||||
className={userCurrentStatus === "declined"
|
||||
? "bg-red-600 hover:bg-red-700 text-white dark:bg-red-500 dark:hover:bg-red-600"
|
||||
: "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"}
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-1" />
|
||||
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
|
||||
{t("participants.declined")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -500,7 +520,7 @@ export function EventModal({
|
||||
<label htmlFor="allDay" className="text-sm">{t("form.all_day_event")}</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("form.start_date")}</label>
|
||||
<input
|
||||
@@ -560,7 +580,7 @@ export function EventModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
|
||||
<select
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
||||
@@ -113,13 +113,17 @@ export function MiniCalendar({
|
||||
<button
|
||||
onClick={handleHeaderClick}
|
||||
disabled={pickerView === "years"}
|
||||
title={pickerView !== "years" ? t("mini_calendar_change") : undefined}
|
||||
className={cn(
|
||||
"text-sm font-medium px-1 rounded transition-colors",
|
||||
"text-sm font-medium px-2 py-1 rounded-md transition-colors inline-flex items-center gap-1",
|
||||
pickerView !== "years" && "hover:bg-muted cursor-pointer",
|
||||
pickerView === "years" && "cursor-default"
|
||||
)}
|
||||
>
|
||||
{headerLabel}
|
||||
{pickerView !== "years" && (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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, ' ') // 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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+111
-177
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+41
-13
@@ -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,38 +58,60 @@ 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} />
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 });
|
||||
},
|
||||
|
||||
|
||||
+23
-15
@@ -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),
|
||||
};
|
||||
Reference in New Issue
Block a user