From a88b4e4407a041887cad7ee5916b5b7acf15ab1e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:19:01 +0100 Subject: [PATCH] feat: enhance folder settings with subfolder creation and navigation improvements --- app/[locale]/calendar/page.tsx | 13 +- app/[locale]/contacts/page.tsx | 13 +- app/[locale]/page.tsx | 2 +- app/[locale]/settings/page.tsx | 15 +- components/email/email-context-menu.tsx | 88 ++++--- components/settings/folder-settings.tsx | 293 +++++++++++++++--------- locales/en/common.json | 3 + 7 files changed, 287 insertions(+), 140 deletions(-) diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index e446d29c..5e79ef35 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -11,6 +11,7 @@ import { } from "date-fns"; import { useCalendarStore } from "@/stores/calendar-store"; import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useIdentityStore } from "@/stores/identity-store"; import { toast } from "@/stores/toast-store"; @@ -44,7 +45,8 @@ export default function CalendarPage() { const router = useRouter(); const t = useTranslations("calendar"); const isMobile = useIsMobile(); - const { client, isAuthenticated } = useAuthStore(); + const { client, isAuthenticated, logout } = useAuthStore(); + const { quota, isPushConnected } = useEmailStore(); const { calendars, events, selectedDate, viewMode, selectedCalendarIds, isLoading, isLoadingEvents, supportsCalendar, error, @@ -637,8 +639,13 @@ export default function CalendarPage() {
{/* Left Navigation Rail */} {!isMobile && ( -
- +
+ { logout(); router.push('/login'); }} + />
)} diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index 0bc2cc50..4a8b33fd 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -17,6 +17,7 @@ import { ContactImportDialog } from "@/components/contacts/contact-import-dialog import { exportContacts } from "@/components/contacts/contact-export"; import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; import { cn } from "@/lib/utils"; import { NavigationRail } from "@/components/layout/navigation-rail"; @@ -37,7 +38,8 @@ type View = export default function ContactsPage() { const router = useRouter(); const t = useTranslations("contacts"); - const { client, isAuthenticated } = useAuthStore(); + const { client, isAuthenticated, logout } = useAuthStore(); + const { quota, isPushConnected } = useEmailStore(); const { contacts, selectedContactId, @@ -440,8 +442,13 @@ export default function ContactsPage() { return (
{!isMobile && ( -
- +
+ { logout(); router.push('/login'); }} + />
)} diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 60062c96..4803a7ff 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -49,7 +49,7 @@ export default function Home() { const [composerDraftText, setComposerDraftText] = useState(""); const [pendingDraft, setPendingDraft] = useState(null); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); - const [initialCheckDone, setInitialCheckDone] = useState(false); + const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [showShortcutsModal, setShowShortcutsModal] = useState(false); const [showAdvancedFields, setShowAdvancedFields] = useState(false); // Mobile conversation view state diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index da573edc..c1fd89cc 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -17,7 +17,9 @@ import { AdvancedSettings } from '@/components/settings/advanced-settings'; import { FolderSettings } from '@/components/settings/folder-settings'; import { KeywordSettings } from '@/components/settings/keyword-settings'; import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; import { useIsDesktop } from '@/hooks/use-media-query'; +import { NavigationRail } from '@/components/layout/navigation-rail'; import { cn } from '@/lib/utils'; type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'folders' | 'keywords' | 'advanced'; @@ -25,7 +27,8 @@ type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'cal export default function SettingsPage() { const router = useRouter(); const t = useTranslations('settings'); - const { client, isAuthenticated } = useAuthStore(); + const { client, isAuthenticated, logout } = useAuthStore(); + const { quota, isPushConnected } = useEmailStore(); const [activeTab, setActiveTab] = useState('appearance'); const [mobileShowContent, setMobileShowContent] = useState(false); const isDesktop = useIsDesktop(); @@ -153,6 +156,16 @@ export default function SettingsPage() { // Desktop layout return (
+ {/* Navigation Rail */} +
+ { logout(); router.push('/login'); }} + /> +
+ {/* Settings Sidebar */}
{/* Header */} diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 4283ae05..058be0fb 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -29,7 +29,7 @@ import { ShieldAlert, ShieldCheck, } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; interface Position { @@ -143,14 +143,31 @@ export function EmailContextMenu({ color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500", })); - // Filter mailboxes for move-to submenu (exclude current, drafts, virtual nodes) - const moveTargets = mailboxes.filter( - (m) => - m.id !== selectedMailbox && - m.role !== "drafts" && - !m.id.startsWith("shared-") && - m.myRights?.mayAddItems + // Build mailbox tree for move-to submenu with proper hierarchy + const moveTargetIds = new Set( + mailboxes + .filter( + (m) => + m.id !== selectedMailbox && + m.role !== "drafts" && + !m.id.startsWith("shared-") && + m.myRights?.mayAddItems + ) + .map((m) => m.id) ); + const mailboxTree = buildMailboxTree(mailboxes); + + // Filter tree to only include branches that contain valid move targets + const filterTree = (nodes: MailboxNode[]): MailboxNode[] => { + return nodes.reduce((acc, node) => { + const filteredChildren = filterTree(node.children); + if (moveTargetIds.has(node.id) || filteredChildren.length > 0) { + acc.push({ ...node, children: filteredChildren }); + } + return acc; + }, []); + }; + const moveTree = filterTree(mailboxTree); const handleAction = (action: () => void) => { action(); @@ -222,25 +239,44 @@ export function EmailContextMenu({ {/* Move to submenu */} - {moveTargets.length > 0 && ( + {moveTree.length > 0 && ( - {moveTargets.map((mailbox) => { - const Icon = getMailboxIcon(mailbox.role); - return ( - - handleAction(() => - showBatchActions - ? onBatchMoveToMailbox?.(mailbox.id) - : onMoveToMailbox?.(mailbox.id) - ) - } - /> - ); - })} + {(() => { + const renderNodes = (nodes: MailboxNode[]) => { + return nodes.map((node) => { + const Icon = getMailboxIcon(node.role); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + handleAction(() => + showBatchActions + ? onBatchMoveToMailbox?.(node.id) + : onMoveToMailbox?.(node.id) + ) + } + /> + ) : ( +
+ + {node.name} +
+ )} + {node.children.length > 0 && ( +
+ {renderNodes(node.children)} +
+ )} +
+ ); + }); + }; + return renderNodes(moveTree); + })()}
)} diff --git a/components/settings/folder-settings.tsx b/components/settings/folder-settings.tsx index c1f6d48d..d1db8945 100644 --- a/components/settings/folder-settings.tsx +++ b/components/settings/folder-settings.tsx @@ -14,7 +14,8 @@ import { Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail, type LucideIcon, } from 'lucide-react'; -import { cn } from '@/lib/utils'; +import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils'; +import { ChevronRight, ChevronDown } from 'lucide-react'; const STANDARD_ROLES = ['inbox', 'drafts', 'sent', 'trash', 'junk', 'archive'] as const; @@ -101,14 +102,17 @@ export function FolderSettings() { const { folderIcons, setFolderIcon } = useSettingsStore(); const [isCreating, setIsCreating] = useState(false); + const [creatingParentId, setCreatingParentId] = useState(null); 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); + const [expandedFolders, setExpandedFolders] = useState>(new Set()); const ownMailboxes = mailboxes.filter(mb => !mb.isShared); + const folderTree = buildMailboxTree(ownMailboxes); const getRoleMailboxId = (role: string): string => { const mb = ownMailboxes.find(m => m.role === role); @@ -139,13 +143,30 @@ export function FolderSettings() { return 'Folder'; }; + const toggleExpanded = (id: string) => { + setExpandedFolders(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const startCreateSubfolder = (parentId: string) => { + setCreatingParentId(parentId); + setIsCreating(true); + setNewFolderName(''); + setExpandedFolders(prev => new Set(prev).add(parentId)); + }; + const handleCreate = async () => { if (!client || !newFolderName.trim()) return; setIsLoading(true); try { - await createMailbox(client, newFolderName.trim()); + await createMailbox(client, newFolderName.trim(), creatingParentId ?? undefined); setNewFolderName(''); setIsCreating(false); + setCreatingParentId(null); toast.success(t('folder_created')); } catch { toast.error(t('error_create')); @@ -213,12 +234,71 @@ export function FolderSettings() { setEditingName(''); }; - const renderFolderRow = (mb: typeof ownMailboxes[0]) => { + const renderCreateInline = (parentId: string | null, depth: number) => { + if (!isCreating || creatingParentId !== parentId) return null; + const parentName = parentId ? ownMailboxes.find(m => m.id === parentId)?.name : null; + return ( +
+ +
+ {parentName && ( + + {t('subfolder_of', { name: parentName })} + + )} + setNewFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreate(); + if (e.key === 'Escape') { + setIsCreating(false); + setNewFolderName(''); + setCreatingParentId(null); + } + }} + placeholder={parentId ? t('subfolder_name') : t('new_folder_name')} + className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + autoFocus + disabled={isLoading} + /> +
+ + +
+ ); + }; + + const renderFolderNode = (node: MailboxNode): React.ReactNode => { + const mb = node; + const hasChildren = node.children.length > 0; + const isExpanded = expandedFolders.has(node.id); + const depth = node.depth; const Icon = getIconForMailbox(mb); if (editingId === mb.id) { return ( -
+
+
+
); } if (deletingId === mb.id) { return ( -
+
+

{t('confirm_delete', { name: mb.name })} @@ -272,74 +354,108 @@ export function FolderSettings() { {t('cancel')}

+
); } return ( -
-
-
- + ) : ( + + )} +
+ + {iconPickerId === mb.id && ( + { + setFolderIcon(mb.id, iconName); + setIconPickerId(null); + }} + onClose={() => setIconPickerId(null)} + /> )} - title={t('change_icon')} - > - - - {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?.mayCreateChild && ( + + )} + {mb.myRights?.mayRename && ( + + )} + {mb.myRights?.mayDelete && !mb.role && ( + )}
- {mb.name} - {mb.role && ( - - {t(`role_${mb.role}`)} - - )} - {mb.unreadEmails > 0 && ( - - {mb.unreadEmails} - - )} -
-
- {mb.myRights?.mayRename && ( - - )} - {mb.myRights?.mayDelete && !mb.role && ( - - )}
+ {/* Inline subfolder creation */} + {renderCreateInline(mb.id, depth + 1)} + {/* Render children if expanded */} + {hasChildren && isExpanded && ( +
+ {node.children.map(child => renderFolderNode(child))} +
+ )}
); }; @@ -349,56 +465,21 @@ export function FolderSettings() { {/* Folder List — primary section */}
- {ownMailboxes.length === 0 ? ( + {folderTree.length === 0 ? (

{t('no_folders')}

) : ( - ownMailboxes.map(renderFolderRow) + folderTree.map(node => renderFolderNode(node)) )}
- {/* Create folder */} - {isCreating ? ( -
- - setNewFolderName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleCreate(); - if (e.key === 'Escape') { - setIsCreating(false); - setNewFolderName(''); - } - }} - placeholder={t('new_folder_name')} - className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" - autoFocus - disabled={isLoading} - /> - - -
- ) : ( + {/* Create top-level folder */} + {renderCreateInline(null, 0)} + {!isCreating && (