feat: compact mail folder tree + right-click Ordner leeren

- MailFolderTree: compact styling matching AI assistant SessionList (py-1, text-sm, depth*12+8 padding, no space-y)
- Added right-click context menu on folders with "Ordner leeren" entry
- Backend: POST /mail/folders/{folder_id}/empty soft-deletes all mails in folder
- Frontend API: emptyFolder() function added to mail.ts
- Mail.tsx: onFolderEmptied callback reloads mails and folders
This commit is contained in:
Agent Zero
2026-07-20 09:25:43 +02:00
parent 35fcd2a9d4
commit 9063093a5a
4 changed files with 154 additions and 23 deletions
+4
View File
@@ -475,6 +475,10 @@ export function deleteFolder(folderId: string): Promise<void> {
return apiDelete<void>(`/mail/folders/${folderId}`);
}
export function emptyFolder(folderId: string): Promise<{ emptied_count: number }> {
return apiPost<{ emptied_count: number }>(`/mail/folders/${folderId}/empty`, {});
}
// ─── Mails ──────────────────────────────────────────────────────────────────
export function fetchMails(folderId: string, page: number, sortBy?: string, sortOrder?: string): Promise<MailListResult> {
+105 -23
View File
@@ -2,12 +2,16 @@
* Mail folder tree sidebar component.
* Shows accounts as top-level nodes with their folders nested underneath.
* Folders come as a flat list with parent_id and are built into a tree.
*
* Styling matches the AI assistant SessionList tree: compact font, tight spacing,
* right-click context menu with "Ordner leeren".
*/
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { MailAccount, MailFolder } from '@/api/mail';
import { emptyFolder } from '@/api/mail';
import { Badge } from '@/components/ui/Badge';
import { EmptyState } from '@/components/ui/EmptyState';
@@ -54,6 +58,7 @@ export interface MailFolderTreeProps {
selectedFolderId: string | null;
onSelect: (folderId: string) => void;
loading: boolean;
onFolderEmptied?: () => void;
}
/**
@@ -84,7 +89,7 @@ function buildFolderTree(folders: MailFolder[], accountId: string): MailFolder[]
function ChevronIcon({ expanded }: { expanded: boolean }) {
return (
<svg
className={clsx('w-3 h-3 transition-transform flex-shrink-0', expanded ? 'rotate-90' : '')}
className={clsx('w-4 h-4 transition-transform flex-shrink-0', expanded && 'rotate-90')}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -97,17 +102,51 @@ function ChevronIcon({ expanded }: { expanded: boolean }) {
function FolderIcon() {
return (
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<svg className="w-4 h-4 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
);
}
function MailIcon() {
interface ContextMenuState {
x: number;
y: number;
folderId: string;
folderName: string;
}
function ContextMenu({
state,
onClose,
onEmptyFolder,
}: {
state: ContextMenuState;
onClose: () => void;
onEmptyFolder: (folderId: string, folderName: string) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [onClose]);
return (
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7-7 7 7M5 19h10a2 2 0 002-2V8l-5-5H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<div
ref={ref}
className="fixed z-50 bg-white border border-secondary-200 rounded-lg shadow-lg py-1 min-w-[160px]"
style={{ left: state.x, top: state.y }}
>
<button
onClick={() => { onEmptyFolder(state.folderId, state.folderName); onClose(); }}
className="w-full text-left px-3 py-1.5 text-sm text-red-600 hover:bg-red-50"
>
Ordner leeren
</button>
</div>
);
}
@@ -116,11 +155,13 @@ function FolderNode({
selectedFolderId,
onSelect,
depth,
onContextMenu,
}: {
folder: MailFolder;
selectedFolderId: string | null;
onSelect: (folderId: string) => void;
depth: number;
onContextMenu: (e: React.MouseEvent, folder: MailFolder) => void;
}) {
const [expanded, setExpanded] = useState(true);
const isSelected = folder.id === selectedFolderId;
@@ -128,7 +169,10 @@ function FolderNode({
return (
<div role="treeitem" aria-selected={isSelected} aria-label={getFolderDisplayName(folder)}>
<div className="flex items-center" style={{ paddingLeft: `${depth * 12}px` }}>
<div
className="flex items-center"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
{hasChildren ? (
<button
onClick={(e) => {
@@ -146,12 +190,13 @@ function FolderNode({
)}
<button
onClick={() => onSelect(folder.id)}
onContextMenu={(e) => onContextMenu(e, folder)}
className={clsx(
'flex-1 flex items-center gap-1.5 px-2 py-1 md:py-1 rounded-md text-sm min-h-touch',
'group flex-1 flex items-center gap-1 px-2 py-1 rounded-md text-sm cursor-pointer',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isSelected
? 'bg-primary-50 text-primary-700 font-medium'
: 'hover:bg-secondary-50 text-secondary-700',
: 'hover:bg-secondary-100 text-secondary-700',
)}
data-testid={`folder-${folder.id}`}
>
@@ -174,6 +219,7 @@ function FolderNode({
selectedFolderId={selectedFolderId}
onSelect={onSelect}
depth={depth + 1}
onContextMenu={onContextMenu}
/>
))}
</div>
@@ -187,11 +233,13 @@ function AccountNode({
folders,
selectedFolderId,
onSelect,
onContextMenu,
}: {
account: MailAccount;
folders: MailFolder[];
selectedFolderId: string | null;
onSelect: (folderId: string) => void;
onContextMenu: (e: React.MouseEvent, folder: MailFolder) => void;
}) {
const [expanded, setExpanded] = useState(true);
const accountFolders = useMemo(
@@ -203,14 +251,14 @@ function AccountNode({
<div role="treeitem" aria-label={account.email}>
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-1 px-1 py-2 md:py-1.5 rounded-md text-sm font-semibold text-secondary-900 hover:bg-secondary-50 min-h-touch"
className="w-full flex items-center gap-1 px-2 py-1.5 rounded-md text-sm font-medium text-secondary-700 hover:bg-secondary-100"
data-testid={`account-${account.id}`}
>
<ChevronIcon expanded={expanded} />
<span className="flex-1 truncate text-left">{account.display_name || account.email}</span>
</button>
{expanded && (
<div role="group" className="ml-2">
<div role="group">
{accountFolders.map((folder) => (
<FolderNode
key={folder.id}
@@ -218,6 +266,7 @@ function AccountNode({
selectedFolderId={selectedFolderId}
onSelect={onSelect}
depth={0}
onContextMenu={onContextMenu}
/>
))}
</div>
@@ -226,8 +275,31 @@ function AccountNode({
);
}
export function MailFolderTree({ accounts, folders, selectedFolderId, onSelect, loading }: MailFolderTreeProps) {
export function MailFolderTree({ accounts, folders, selectedFolderId, onSelect, loading, onFolderEmptied }: MailFolderTreeProps) {
const { t } = useTranslation();
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const handleContextMenu = useCallback((e: React.MouseEvent, folder: MailFolder) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
x: e.clientX,
y: e.clientY,
folderId: folder.id,
folderName: getFolderDisplayName(folder),
});
}, []);
const handleEmptyFolder = useCallback(async (folderId: string, folderName: string) => {
if (!confirm(`Ordner "${folderName}" wirklich leeren? Alle E-Mails werden gelöscht.`)) return;
try {
const result = await emptyFolder(folderId);
if (onFolderEmptied) onFolderEmptied();
} catch (e) {
console.error('Failed to empty folder:', e);
alert('Fehler beim Leeren des Ordners: ' + (e instanceof Error ? e.message : String(e)));
}
}, [onFolderEmptied]);
if (loading) {
return (
@@ -252,16 +324,26 @@ export function MailFolderTree({ accounts, folders, selectedFolderId, onSelect,
}
return (
<div className="space-y-1" role="tree" data-testid="folder-tree-list">
{accounts.map((account) => (
<AccountNode
key={account.id}
account={account}
folders={folders}
selectedFolderId={selectedFolderId}
onSelect={onSelect}
<>
<div className="flex flex-col" role="tree" data-testid="folder-tree-list">
{accounts.map((account) => (
<AccountNode
key={account.id}
account={account}
folders={folders}
selectedFolderId={selectedFolderId}
onSelect={onSelect}
onContextMenu={handleContextMenu}
/>
))}
</div>
{contextMenu && (
<ContextMenu
state={contextMenu}
onClose={() => setContextMenu(null)}
onEmptyFolder={handleEmptyFolder}
/>
))}
</div>
)}
</>
);
}
+1
View File
@@ -736,6 +736,7 @@ export function MailPage() {
selectedFolderId={selectedFolderId}
onSelect={handleSelectFolder}
loading={loadingFolders}
onFolderEmptied={() => { loadMails(); loadAllFolders(); }}
/>
</div>
</ResizablePanel>