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:
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -736,6 +736,7 @@ export function MailPage() {
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelect={handleSelectFolder}
|
||||
loading={loadingFolders}
|
||||
onFolderEmptied={() => { loadMails(); loadAllFolders(); }}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
Reference in New Issue
Block a user