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:
@@ -587,6 +587,50 @@ async def delete_folder(
|
|||||||
await db.delete(folder)
|
await db.delete(folder)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Empty Folder (soft-delete all mails in folder) ───
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/folders/{folder_id}/empty")
|
||||||
|
async def empty_folder(
|
||||||
|
folder_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("mail:delete")),
|
||||||
|
):
|
||||||
|
"""Soft-delete all mails in a folder (sets deleted_at = now()).
|
||||||
|
|
||||||
|
The mails remain in the database but are hidden from the normal list.
|
||||||
|
Returns the number of mails that were emptied.
|
||||||
|
"""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
f_id = _parse_uuid(folder_id, "folder_id")
|
||||||
|
folder = (
|
||||||
|
await db.execute(
|
||||||
|
select(MailFolder).where(and_(MailFolder.id == f_id, MailFolder.tenant_id == tenant_id))
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not folder:
|
||||||
|
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
||||||
|
account = await _get_account(db, folder.account_id, tenant_id, user_id)
|
||||||
|
await _check_delegate_access(db, account, user_id, "delete")
|
||||||
|
# Soft-delete all non-deleted mails in this folder
|
||||||
|
result = await db.execute(
|
||||||
|
select(Mail).where(
|
||||||
|
and_(
|
||||||
|
Mail.folder_id == f_id,
|
||||||
|
Mail.tenant_id == tenant_id,
|
||||||
|
Mail.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mails = result.scalars().all()
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
for mail in mails:
|
||||||
|
mail.deleted_at = now
|
||||||
|
await db.flush()
|
||||||
|
return {"emptied_count": len(mails)}
|
||||||
|
|
||||||
|
|
||||||
# ─── Attachment Upload (F-MAIL-04) ───
|
# ─── Attachment Upload (F-MAIL-04) ───
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -475,6 +475,10 @@ export function deleteFolder(folderId: string): Promise<void> {
|
|||||||
return apiDelete<void>(`/mail/folders/${folderId}`);
|
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 ──────────────────────────────────────────────────────────────────
|
// ─── Mails ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function fetchMails(folderId: string, page: number, sortBy?: string, sortOrder?: string): Promise<MailListResult> {
|
export function fetchMails(folderId: string, page: number, sortBy?: string, sortOrder?: string): Promise<MailListResult> {
|
||||||
|
|||||||
@@ -2,12 +2,16 @@
|
|||||||
* Mail folder tree sidebar component.
|
* Mail folder tree sidebar component.
|
||||||
* Shows accounts as top-level nodes with their folders nested underneath.
|
* 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.
|
* 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 clsx from 'clsx';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { MailAccount, MailFolder } from '@/api/mail';
|
import type { MailAccount, MailFolder } from '@/api/mail';
|
||||||
|
import { emptyFolder } from '@/api/mail';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
|
||||||
@@ -54,6 +58,7 @@ export interface MailFolderTreeProps {
|
|||||||
selectedFolderId: string | null;
|
selectedFolderId: string | null;
|
||||||
onSelect: (folderId: string) => void;
|
onSelect: (folderId: string) => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
onFolderEmptied?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,7 +89,7 @@ function buildFolderTree(folders: MailFolder[], accountId: string): MailFolder[]
|
|||||||
function ChevronIcon({ expanded }: { expanded: boolean }) {
|
function ChevronIcon({ expanded }: { expanded: boolean }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<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"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -97,17 +102,51 @@ function ChevronIcon({ expanded }: { expanded: boolean }) {
|
|||||||
|
|
||||||
function FolderIcon() {
|
function FolderIcon() {
|
||||||
return (
|
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" />
|
<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>
|
</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 (
|
return (
|
||||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
<div
|
||||||
<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" />
|
ref={ref}
|
||||||
</svg>
|
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,
|
selectedFolderId,
|
||||||
onSelect,
|
onSelect,
|
||||||
depth,
|
depth,
|
||||||
|
onContextMenu,
|
||||||
}: {
|
}: {
|
||||||
folder: MailFolder;
|
folder: MailFolder;
|
||||||
selectedFolderId: string | null;
|
selectedFolderId: string | null;
|
||||||
onSelect: (folderId: string) => void;
|
onSelect: (folderId: string) => void;
|
||||||
depth: number;
|
depth: number;
|
||||||
|
onContextMenu: (e: React.MouseEvent, folder: MailFolder) => void;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
const isSelected = folder.id === selectedFolderId;
|
const isSelected = folder.id === selectedFolderId;
|
||||||
@@ -128,7 +169,10 @@ function FolderNode({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div role="treeitem" aria-selected={isSelected} aria-label={getFolderDisplayName(folder)}>
|
<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 ? (
|
{hasChildren ? (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -146,12 +190,13 @@ function FolderNode({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => onSelect(folder.id)}
|
onClick={() => onSelect(folder.id)}
|
||||||
|
onContextMenu={(e) => onContextMenu(e, folder)}
|
||||||
className={clsx(
|
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',
|
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-primary-50 text-primary-700 font-medium'
|
? '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}`}
|
data-testid={`folder-${folder.id}`}
|
||||||
>
|
>
|
||||||
@@ -174,6 +219,7 @@ function FolderNode({
|
|||||||
selectedFolderId={selectedFolderId}
|
selectedFolderId={selectedFolderId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -187,11 +233,13 @@ function AccountNode({
|
|||||||
folders,
|
folders,
|
||||||
selectedFolderId,
|
selectedFolderId,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onContextMenu,
|
||||||
}: {
|
}: {
|
||||||
account: MailAccount;
|
account: MailAccount;
|
||||||
folders: MailFolder[];
|
folders: MailFolder[];
|
||||||
selectedFolderId: string | null;
|
selectedFolderId: string | null;
|
||||||
onSelect: (folderId: string) => void;
|
onSelect: (folderId: string) => void;
|
||||||
|
onContextMenu: (e: React.MouseEvent, folder: MailFolder) => void;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
const accountFolders = useMemo(
|
const accountFolders = useMemo(
|
||||||
@@ -203,14 +251,14 @@ function AccountNode({
|
|||||||
<div role="treeitem" aria-label={account.email}>
|
<div role="treeitem" aria-label={account.email}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded(!expanded)}
|
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}`}
|
data-testid={`account-${account.id}`}
|
||||||
>
|
>
|
||||||
<ChevronIcon expanded={expanded} />
|
<ChevronIcon expanded={expanded} />
|
||||||
<span className="flex-1 truncate text-left">{account.display_name || account.email}</span>
|
<span className="flex-1 truncate text-left">{account.display_name || account.email}</span>
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div role="group" className="ml-2">
|
<div role="group">
|
||||||
{accountFolders.map((folder) => (
|
{accountFolders.map((folder) => (
|
||||||
<FolderNode
|
<FolderNode
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
@@ -218,6 +266,7 @@ function AccountNode({
|
|||||||
selectedFolderId={selectedFolderId}
|
selectedFolderId={selectedFolderId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
depth={0}
|
depth={0}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 { 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -252,7 +324,8 @@ export function MailFolderTree({ accounts, folders, selectedFolderId, onSelect,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1" role="tree" data-testid="folder-tree-list">
|
<>
|
||||||
|
<div className="flex flex-col" role="tree" data-testid="folder-tree-list">
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<AccountNode
|
<AccountNode
|
||||||
key={account.id}
|
key={account.id}
|
||||||
@@ -260,8 +333,17 @@ export function MailFolderTree({ accounts, folders, selectedFolderId, onSelect,
|
|||||||
folders={folders}
|
folders={folders}
|
||||||
selectedFolderId={selectedFolderId}
|
selectedFolderId={selectedFolderId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{contextMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
state={contextMenu}
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
onEmptyFolder={handleEmptyFolder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -736,6 +736,7 @@ export function MailPage() {
|
|||||||
selectedFolderId={selectedFolderId}
|
selectedFolderId={selectedFolderId}
|
||||||
onSelect={handleSelectFolder}
|
onSelect={handleSelectFolder}
|
||||||
loading={loadingFolders}
|
loading={loadingFolders}
|
||||||
|
onFolderEmptied={() => { loadMails(); loadAllFolders(); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|||||||
Reference in New Issue
Block a user