|
|
|
@@ -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,7 +324,8 @@ export function MailFolderTree({ accounts, folders, selectedFolderId, onSelect,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) => (
|
|
|
|
|
<AccountNode
|
|
|
|
|
key={account.id}
|
|
|
|
@@ -260,8 +333,17 @@ export function MailFolderTree({ accounts, folders, selectedFolderId, onSelect,
|
|
|
|
|
folders={folders}
|
|
|
|
|
selectedFolderId={selectedFolderId}
|
|
|
|
|
onSelect={onSelect}
|
|
|
|
|
onContextMenu={handleContextMenu}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
{contextMenu && (
|
|
|
|
|
<ContextMenu
|
|
|
|
|
state={contextMenu}
|
|
|
|
|
onClose={() => setContextMenu(null)}
|
|
|
|
|
onEmptyFolder={handleEmptyFolder}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|