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 (
-
-
-
-