diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 7497b9f2..1f600100 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useRef, useMemo } from "react"; +import { useEffect, useState, useRef, useMemo, useCallback } from "react"; import { useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; import { Sidebar } from "@/components/layout/sidebar"; @@ -29,11 +29,13 @@ import { ComposerErrorFallback, } from "@/components/error"; import { DragDropProvider } from "@/contexts/drag-drop-context"; -import { AdvancedSearchPanel } from "@/components/search/advanced-search-panel"; -import { isFilterEmpty } from "@/lib/jmap/search-utils"; +import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils"; import { WelcomeBanner } from "@/components/ui/welcome-banner"; import { NavigationRail } from "@/components/layout/navigation-rail"; +import { Input } from "@/components/ui/input"; +import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw } from "lucide-react"; import { ResizeHandle } from "@/components/layout/resize-handle"; +import { Button } from "@/components/ui/button"; export default function Home() { const router = useRouter(); @@ -44,6 +46,7 @@ export default function Home() { const [composerDraftText, setComposerDraftText] = useState(""); const [initialCheckDone, setInitialCheckDone] = useState(false); const [showShortcutsModal, setShowShortcutsModal] = useState(false); + const [showAdvancedFields, setShowAdvancedFields] = useState(false); // Mobile conversation view state const [conversationThread, setConversationThread] = useState(null); const [conversationEmails, setConversationEmails] = useState([]); @@ -604,6 +607,24 @@ export default function Home() { await advancedSearch(client); }; + const advancedSearchDebounceRef = useRef(null); + const handleAdvancedSearchDebounced = useCallback(() => { + if (advancedSearchDebounceRef.current) { + clearTimeout(advancedSearchDebounceRef.current); + } + advancedSearchDebounceRef.current = setTimeout(() => { + if (client) advancedSearch(client); + }, 300); + }, [client, advancedSearch]); + + useEffect(() => { + return () => { + if (advancedSearchDebounceRef.current) { + clearTimeout(advancedSearchDebounceRef.current); + } + }; + }, []); + const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => { if (!client) return; @@ -744,6 +765,22 @@ export default function Home() { setShowComposer(true); }; + const ToggleChip = ({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: boolean | null; onClick: () => void }) => ( + + ); + return (
@@ -791,9 +828,6 @@ export default function Home() { if (isMobile) setSidebarOpen(false); }} onSidebarClose={() => setSidebarOpen(false)} - onSearch={handleSearch} - onClearSearch={handleClearSearch} - activeSearchQuery={searchQuery} />
@@ -834,17 +868,181 @@ export default function Home() { }} /> - { - clearSearchFilters(); - if (client) advancedSearch(client); - }} - onSearch={handleAdvancedSearch} - onClose={toggleAdvancedSearch} - /> + {/* Search Bar + Inline Advanced Filters */} +
+
+
+
{ e.preventDefault(); if (searchQuery.trim()) handleSearch(searchQuery); }} className="relative flex-1"> + + setSearchQuery(e.target.value)} + className={cn("pl-9 h-9", searchQuery && "pr-8")} + data-search-input + /> + {searchQuery && ( + + )} + + +
+
+ + {/* Filter Area */} + {isAdvancedSearchOpen && ( +
+ {/* Quick toggle filters + clear */} +
+
+ } + label={t("advanced_search.has_attachment")} + value={searchFilters.hasAttachment} + onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment === true ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }} + /> + } + label={t("advanced_search.starred")} + value={searchFilters.isStarred} + onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred === true ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }} + /> + : } + label={searchFilters.isUnread === false ? t("advanced_search.read") : t("advanced_search.unread")} + value={searchFilters.isUnread} + onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread === true ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }} + /> +
+
+ +
+
+ + {/* "More" expand for advanced fields */} + + + {/* Advanced fields */} + {showAdvancedFields && ( +
+
+
+ + { setSearchFilters({ from: e.target.value }); handleAdvancedSearchDebounced(); }} + placeholder={t("advanced_search.from_placeholder")} + className="h-8 text-sm" + /> +
+
+ + { setSearchFilters({ to: e.target.value }); handleAdvancedSearchDebounced(); }} + placeholder={t("advanced_search.to_placeholder")} + className="h-8 text-sm" + /> +
+
+ +
+ + { setSearchFilters({ subject: e.target.value }); handleAdvancedSearchDebounced(); }} + placeholder={t("advanced_search.subject_placeholder")} + className="h-8 text-sm" + /> +
+ +
+ + { setSearchFilters({ body: e.target.value }); handleAdvancedSearchDebounced(); }} + placeholder={t("advanced_search.body_placeholder")} + className="h-8 text-sm" + /> +
+ + {/* Folder selector */} +
+ + +
+ +
+
+ + { setSearchFilters({ dateAfter: e.target.value }); handleAdvancedSearch(); }} + className="h-8 text-sm" + /> +
+
+ + { setSearchFilters({ dateBefore: e.target.value }); handleAdvancedSearch(); }} + className="h-8 text-sm" + /> +
+
+
+ )} +
+ )} +
diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 0cf5424b..a58dd15b 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useRouter } from '@/i18n/navigation'; import { useTranslations } from 'next-intl'; import { ArrowLeft, Settings as SettingsIcon } from 'lucide-react'; @@ -23,9 +23,19 @@ type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'cal export default function SettingsPage() { const router = useRouter(); const t = useTranslations('settings'); - const { client } = useAuthStore(); + const { client, isAuthenticated } = useAuthStore(); const [activeTab, setActiveTab] = useState('appearance'); + useEffect(() => { + if (!isAuthenticated) { + router.push('/login'); + } + }, [isAuthenticated, router]); + + if (!isAuthenticated) { + return null; + } + const supportsVacation = client?.supportsVacationResponse() ?? false; const supportsCalendar = client?.supportsCalendars() ?? false; const supportsSieve = client?.supportsSieve() ?? false; diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts new file mode 100644 index 00000000..095fc793 --- /dev/null +++ b/app/api/favicon/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// In-memory LRU cache: domain -> { data, contentType, fetchedAt } +const CACHE_MAX_SIZE = 1000; +const CACHE_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 2 weeks + +interface CacheEntry { + data: ArrayBuffer; + contentType: string; + fetchedAt: number; +} + +const cache = new Map(); + +// Strict domain validation to prevent SSRF +const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i; + +function isValidDomain(domain: string): boolean { + if (domain.length > 253) return false; + if (!DOMAIN_RE.test(domain)) return false; + // Block internal/private hostnames + const lower = domain.toLowerCase(); + if ( + lower === 'localhost' || + lower.endsWith('.local') || + lower.endsWith('.internal') || + lower.endsWith('.arpa') + ) { + return false; + } + return true; +} + +function evictOldest() { + if (cache.size < CACHE_MAX_SIZE) return; + // Evict the oldest entry + let oldestKey: string | null = null; + let oldestTime = Infinity; + for (const [key, entry] of cache) { + if (entry.fetchedAt < oldestTime) { + oldestTime = entry.fetchedAt; + oldestKey = key; + } + } + if (oldestKey) cache.delete(oldestKey); +} + +export async function GET(request: NextRequest) { + const domain = request.nextUrl.searchParams.get('domain'); + + if (!domain || !isValidDomain(domain)) { + return new NextResponse(null, { status: 400 }); + } + + const normalizedDomain = domain.toLowerCase(); + + // Check cache + const cached = cache.get(normalizedDomain); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return new NextResponse(cached.data, { + headers: { + 'Content-Type': cached.contentType, + 'Cache-Control': 'public, max-age=1209600', // 2 weeks + }, + }); + } + + try { + const upstream = await fetch( + `https://icons.duckduckgo.com/ip3/${encodeURIComponent(normalizedDomain)}.ico`, + { signal: AbortSignal.timeout(5000) } + ); + + if (!upstream.ok) { + return new NextResponse(null, { status: 404 }); + } + + const contentType = upstream.headers.get('content-type') || 'image/x-icon'; + const data = await upstream.arrayBuffer(); + + // Don't cache empty/tiny responses (likely no real favicon) + if (data.byteLength < 10) { + return new NextResponse(null, { status: 404 }); + } + + // Cache the result + evictOldest(); + cache.set(normalizedDomain, { data, contentType, fetchedAt: Date.now() }); + + return new NextResponse(data, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=1209600', + }, + }); + } catch { + return new NextResponse(null, { status: 502 }); + } +} diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 57d32440..88e95db0 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -102,10 +102,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email onContextMenu={handleContextMenu} style={{ minHeight: 'var(--list-item-height)' }} > -
+
{/* Checkbox with smooth animation */} -

- {isLoading ? t('loading') : threadGroups.length > 0 - ? (totalEmails !== undefined && totalEmails > threadGroups.length - ? t('conversations_count', { count: threadGroups.length, total: totalEmails }) - : hasMoreEmails - ? t('conversations_count_plus', { count: threadGroups.length }) - : t('conversations_count_simple', { count: threadGroups.length })) - : t('no_conversations')} -

-
-
+ {/* Email List */}
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 7a9d7457..d5ee48e7 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -96,12 +96,7 @@ const SingleEmailItem = React.forwardRef( onContextMenu={handleContextMenu} style={{ minHeight: 'var(--list-item-height)' }} > -
-
- +
{isUnread && (
@@ -275,10 +270,7 @@ export const ThreadListItem = React.forwardRef -
+
{!isMobile && ( - {open && ( -
+ {open && createPortal( +

{t("storage")}

@@ -102,7 +121,8 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to

{Math.round(usagePercent)}% {t("storage_used").toLowerCase()}

-
+
, + document.body )}
); diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 31fdad1b..91678bad 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -4,7 +4,6 @@ import { useState, useEffect } from "react"; import { useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Inbox, Send, @@ -13,8 +12,8 @@ import { Trash2, Archive, PenSquare, - Search, - Menu, + ChevronsLeft, + ChevronsRight, ChevronRight, ChevronDown, Folder, @@ -22,7 +21,6 @@ import { Users, User, Palmtree, - SlidersHorizontal, Settings, X, } from "lucide-react"; @@ -30,9 +28,8 @@ import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { Mailbox } from "@/lib/jmap/types"; import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useMailboxDrop } from "@/hooks/use-mailbox-drop"; -import { useEmailStore } from "@/stores/email-store"; import { useUIStore } from "@/stores/ui-store"; -import { activeFilterCount } from "@/lib/jmap/search-utils"; +import { useAuthStore } from "@/stores/auth-store"; import { useVacationStore } from "@/stores/vacation-store"; import { toast } from "@/stores/toast-store"; import { debug } from "@/lib/debug"; @@ -43,9 +40,6 @@ interface SidebarProps { onMailboxSelect?: (mailboxId: string) => void; onCompose?: () => void; onSidebarClose?: () => void; - onSearch?: (query: string) => void; - onClearSearch?: () => void; - activeSearchQuery?: string; className?: string; } @@ -126,7 +120,8 @@ function MailboxTreeItem({
- {hasChildren && ( + {hasChildren && !isCollapsed && ( - ); -} - export function Sidebar({ mailboxes = [], selectedMailbox = "", onMailboxSelect, onCompose, onSidebarClose, - onSearch, - onClearSearch, - activeSearchQuery = "", className, }: SidebarProps) { const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore(); - const [searchQuery, setSearchQuery] = useState(""); + const { primaryIdentity } = useAuthStore(); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const t = useTranslations('sidebar'); - useEffect(() => { - setSearchQuery(activeSearchQuery); - }, [activeSearchQuery]); - useEffect(() => { const stored = localStorage.getItem('expandedMailboxes'); if (stored) { @@ -323,13 +286,6 @@ export function Sidebar({ }); }; - const handleSearch = (e: React.FormEvent) => { - e.preventDefault(); - if (searchQuery.trim() && onSearch) { - onSearch(searchQuery); - } - }; - const mailboxTree = buildMailboxTree(mailboxes); useEffect(() => { @@ -369,12 +325,12 @@ export function Sidebar({ "relative flex flex-col h-full border-r transition-all duration-300 overflow-hidden", "bg-secondary border-border", "max-lg:w-full", - isCollapsed ? "lg:w-16" : "lg:w-full", + isCollapsed ? "lg:w-12" : "lg:w-full", className )} > {/* Header */} -
+
- {!isCollapsed && ( - + {!isCollapsed && primaryIdentity && ( +
+

+ {primaryIdentity.name} +

+

+ {primaryIdentity.email} +

+
)}
{/* Vacation Banner */} {!isCollapsed && } - {/* Search + Advanced Filter Toggle */} - {!isCollapsed && ( -
-
-
- - setSearchQuery(e.target.value)} - className={cn("pl-9", searchQuery && "pr-8")} - data-search-input - /> - {searchQuery && ( - - )} - - -
-
- )} - {/* Mailbox List */}
@@ -463,7 +391,19 @@ export function Sidebar({
- {/* Footer removed - storage quota and sign out moved to NavigationRail */} + {/* Compose Button */} +
+ {isCollapsed ? ( + + ) : ( + + )} +
); } diff --git a/components/settings/email-settings.tsx b/components/settings/email-settings.tsx index baac6ff9..ee13b5e9 100644 --- a/components/settings/email-settings.tsx +++ b/components/settings/email-settings.tsx @@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { TrustedSendersModal } from '@/components/trusted-senders-modal'; -import { ChevronRight } from 'lucide-react'; +import { ChevronRight, AlertTriangle } from 'lucide-react'; export function EmailSettings() { const t = useTranslations('settings.email_behavior'); @@ -47,14 +47,22 @@ export function EmailSettings() { {/* Delete Action */} - updateSetting('deleteAction', value as 'trash' | 'permanent')} + options={[ + { value: 'trash', label: t('delete_action.trash') }, + { value: 'permanent', label: t('delete_action.permanent') }, + ]} + /> + {deleteAction === 'permanent' && ( +
+ + {t('delete_action.warning')} +
+ )} +
{/* Show Preview */} diff --git a/components/settings/folder-settings.tsx b/components/settings/folder-settings.tsx index eec61e26..c1f6d48d 100644 --- a/components/settings/folder-settings.tsx +++ b/components/settings/folder-settings.tsx @@ -1,28 +1,113 @@ "use client"; -import { useState } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { useTranslations } from 'next-intl'; import { useEmailStore } from '@/stores/email-store'; import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { toast } from '@/stores/toast-store'; import { SettingsSection, SettingItem, Select } from './settings-section'; -import { Plus, Pencil, Trash2, Check, X, FolderPlus } from 'lucide-react'; +import { + Plus, Pencil, Trash2, Check, X, FolderPlus, Folder, + Inbox, Send, FileText, Trash, ShieldAlert, Archive, + Star, Heart, Bookmark, Tag, Flag, Briefcase, Users, + Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail, + type LucideIcon, +} from 'lucide-react'; import { cn } from '@/lib/utils'; const STANDARD_ROLES = ['inbox', 'drafts', 'sent', 'trash', 'junk', 'archive'] as const; +const ROLE_ICONS: Record = { + inbox: Inbox, + drafts: FileText, + sent: Send, + trash: Trash, + junk: ShieldAlert, + archive: Archive, +}; + +const ICON_CHOICES: { name: string; icon: LucideIcon }[] = [ + { name: 'Folder', icon: Folder }, + { name: 'Star', icon: Star }, + { name: 'Heart', icon: Heart }, + { name: 'Bookmark', icon: Bookmark }, + { name: 'Tag', icon: Tag }, + { name: 'Flag', icon: Flag }, + { name: 'Briefcase', icon: Briefcase }, + { name: 'Users', icon: Users }, + { name: 'Bell', icon: Bell }, + { name: 'Zap', icon: Zap }, + { name: 'Globe', icon: Globe }, + { name: 'Lock', icon: Lock }, + { name: 'Eye', icon: Eye }, + { name: 'MessageSquare', icon: MessageSquare }, + { name: 'Mail', icon: Mail }, + { name: 'Inbox', icon: Inbox }, + { name: 'Archive', icon: Archive }, + { name: 'FileText', icon: FileText }, +]; + +function IconPicker({ currentIcon, onSelect, onClose }: { + currentIcon: string; + onSelect: (iconName: string) => void; + onClose: () => void; +}) { + const ref = useRef(null); + + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('mousedown', handleClick); + document.addEventListener('keydown', handleKey); + return () => { + document.removeEventListener('mousedown', handleClick); + document.removeEventListener('keydown', handleKey); + }; + }, [onClose]); + + return ( +
+ {ICON_CHOICES.map(({ name, icon: Icon }) => ( + + ))} +
+ ); +} + export function FolderSettings() { const t = useTranslations('settings.folders'); const { client } = useAuthStore(); const { mailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore(); + const { folderIcons, setFolderIcon } = useSettingsStore(); const [isCreating, setIsCreating] = useState(false); const [newFolderName, setNewFolderName] = useState(''); const [editingId, setEditingId] = useState(null); const [editingName, setEditingName] = useState(''); const [deletingId, setDeletingId] = useState(null); + const [iconPickerId, setIconPickerId] = useState(null); const [isLoading, setIsLoading] = useState(false); - // Only show own (non-shared) mailboxes const ownMailboxes = mailboxes.filter(mb => !mb.isShared); const getRoleMailboxId = (role: string): string => { @@ -30,6 +115,30 @@ export function FolderSettings() { return mb?.id ?? ''; }; + const getIconForMailbox = (mb: { id: string; role?: string }): LucideIcon => { + // Custom icon takes priority for non-role folders + const customIconName = folderIcons[mb.id]; + if (customIconName) { + const found = ICON_CHOICES.find(c => c.name === customIconName); + if (found) return found.icon; + } + // Role folders get their role icon + if (mb.role && ROLE_ICONS[mb.role]) return ROLE_ICONS[mb.role]; + return Folder; + }; + + const getIconName = (mb: { id: string; role?: string }): string => { + if (folderIcons[mb.id]) return folderIcons[mb.id]; + if (mb.role && ROLE_ICONS[mb.role]) { + const entry = Object.entries(ROLE_ICONS).find(([r]) => r === mb.role); + if (entry) { + const found = ICON_CHOICES.find(c => c.icon === entry[1]); + if (found) return found.name; + } + } + return 'Folder'; + }; + const handleCreate = async () => { if (!client || !newFolderName.trim()) return; setIsLoading(true); @@ -37,8 +146,9 @@ export function FolderSettings() { await createMailbox(client, newFolderName.trim()); setNewFolderName(''); setIsCreating(false); + toast.success(t('folder_created')); } catch { - // error is set in the store + toast.error(t('error_create')); } finally { setIsLoading(false); } @@ -51,8 +161,9 @@ export function FolderSettings() { await renameMailbox(client, mailboxId, editingName.trim()); setEditingId(null); setEditingName(''); + toast.success(t('folder_renamed')); } catch { - // error is set in the store + toast.error(t('error_rename')); } finally { setIsLoading(false); } @@ -64,8 +175,9 @@ export function FolderSettings() { try { await deleteMailbox(client, mailboxId); setDeletingId(null); + toast.success(t('folder_deleted')); } catch { - // error is set in the store + toast.error(t('error_delete')); } finally { setIsLoading(false); } @@ -76,7 +188,6 @@ export function FolderSettings() { setIsLoading(true); try { if (mailboxId === '') { - // Clear the role from whatever mailbox currently has it const current = ownMailboxes.find(m => m.role === role); if (current) { await setMailboxRole(client, current.id, null); @@ -84,8 +195,9 @@ export function FolderSettings() { } else { await setMailboxRole(client, mailboxId, role); } + toast.success(t('role_updated')); } catch { - // error is set in the store + toast.error(t('error_role')); } finally { setIsLoading(false); } @@ -101,126 +213,156 @@ export function FolderSettings() { setEditingName(''); }; + const renderFolderRow = (mb: typeof ownMailboxes[0]) => { + const Icon = getIconForMailbox(mb); + + if (editingId === mb.id) { + return ( +
+ + setEditingName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleRename(mb.id); + if (e.key === 'Escape') cancelEdit(); + }} + className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring" + autoFocus + disabled={isLoading} + /> + + +
+ ); + } + + if (deletingId === mb.id) { + return ( +
+ +

+ {t('confirm_delete', { name: mb.name })} +

+ + +
+ ); + } + + return ( +
+
+
+ + {iconPickerId === mb.id && ( + { + setFolderIcon(mb.id, iconName); + setIconPickerId(null); + }} + onClose={() => setIconPickerId(null)} + /> + )} +
+ {mb.name} + {mb.role && ( + + {t(`role_${mb.role}`)} + + )} + {mb.unreadEmails > 0 && ( + + {mb.unreadEmails} + + )} +
+
+ {mb.myRights?.mayRename && ( + + )} + {mb.myRights?.mayDelete && !mb.role && ( + + )} +
+
+ ); + }; + return (
- {/* Standard Folder Roles */} - - {STANDARD_ROLES.map((role) => ( - - setEditingName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleRename(mb.id); - if (e.key === 'Escape') cancelEdit(); - }} - className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring" - autoFocus - disabled={isLoading} - /> - - -
- ) : deletingId === mb.id ? ( -
-

- {t('confirm_delete', { name: mb.name })} -

- - -
- ) : ( - <> -
- {mb.name} - {mb.role && ( - - {mb.role} - - )} -
-
- {mb.myRights?.mayRename && ( - - )} - {mb.myRights?.mayDelete && !mb.role && ( - - )} -
- - )} + {/* Folder List — primary section */} + +
+ {ownMailboxes.length === 0 ? ( +
+ +

{t('no_folders')}

- ))} + ) : ( + ownMailboxes.map(renderFolderRow) + )}
{/* Create folder */} {isCreating ? ( -
- +
+ {t('create')} @@ -249,7 +391,7 @@ export function FolderSettings() { setIsCreating(false); setNewFolderName(''); }} - className="px-3 py-1 text-xs bg-muted text-foreground rounded hover:bg-accent" + className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent" > {t('cancel')} @@ -257,13 +399,32 @@ export function FolderSettings() { ) : ( )} + + {/* Standard Folder Roles — advanced section */} + + {STANDARD_ROLES.map((role) => ( + +