feat(mail): resizable columns, account in folder tree, mail settings in CRM settings
- Add ResizablePanel component (components/ui/ResizablePanel.tsx) with
drag-to-resize via mouse events, no external dependencies
- Replace fixed-width columns in Mail.tsx with ResizablePanel for all
three panes (folder tree, mail list, mail detail)
- Remove account-select toolbar item from Mail.tsx; account selection
is now integrated into the folder tree
- Restructure MailFolderTree to show mail accounts as top-level nodes
with folders nested underneath, built from flat list via parent_id
- Add expand/collapse chevrons for accounts and folders with children
- Load folders for ALL accounts (not just selected one) so multi-account
tree works
- Derive selectedAccountId from selected folder instead of separate
account selector
- Add Mail tab to CRM Settings page (Settings.tsx) with 📧 icon
- Add /settings/mail route pointing to MailSettingsPage
- Update settings link in mail toolbar to point to /settings/mail
- Update no-accounts empty state link to /settings/mail
This commit is contained in:
@@ -1,22 +1,79 @@
|
|||||||
/**
|
/**
|
||||||
* Mail folder tree sidebar component.
|
* Mail folder tree sidebar component.
|
||||||
* Shows hierarchical folder list with unread badges.
|
* 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { MailFolder } from '@/api/mail';
|
import type { MailAccount, MailFolder } 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';
|
||||||
|
|
||||||
export interface MailFolderTreeProps {
|
export interface MailFolderTreeProps {
|
||||||
|
accounts: MailAccount[];
|
||||||
folders: MailFolder[];
|
folders: MailFolder[];
|
||||||
selectedFolderId: string | null;
|
selectedFolderId: string | null;
|
||||||
onSelect: (folderId: string) => void;
|
onSelect: (folderId: string) => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a hierarchical tree from a flat folder list for a given account.
|
||||||
|
*/
|
||||||
|
function buildFolderTree(folders: MailFolder[], accountId: string): MailFolder[] {
|
||||||
|
const accountFolders = folders.filter((f) => f.account_id === accountId);
|
||||||
|
const byId = new Map<string, MailFolder & { children: MailFolder[] }>();
|
||||||
|
|
||||||
|
for (const f of accountFolders) {
|
||||||
|
byId.set(f.id, { ...f, children: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots: MailFolder[] = [];
|
||||||
|
|
||||||
|
for (const f of accountFolders) {
|
||||||
|
const node = byId.get(f.id)!;
|
||||||
|
if (f.parent_id && byId.has(f.parent_id)) {
|
||||||
|
byId.get(f.parent_id)!.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronIcon({ expanded }: { expanded: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={clsx('w-3 h-3 transition-transform flex-shrink-0', expanded ? 'rotate-90' : '')}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderIcon() {
|
||||||
|
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 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() {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FolderNode({
|
function FolderNode({
|
||||||
folder,
|
folder,
|
||||||
selectedFolderId,
|
selectedFolderId,
|
||||||
@@ -28,36 +85,51 @@ function FolderNode({
|
|||||||
onSelect: (folderId: string) => void;
|
onSelect: (folderId: string) => void;
|
||||||
depth: number;
|
depth: number;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const [expanded, setExpanded] = useState(true);
|
||||||
const isSelected = folder.id === selectedFolderId;
|
const isSelected = folder.id === selectedFolderId;
|
||||||
const hasChildren = folder.children && folder.children.length > 0;
|
const hasChildren = folder.children && folder.children.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div role="treeitem" aria-selected={isSelected} aria-label={folder.name}>
|
<div role="treeitem" aria-selected={isSelected} aria-label={folder.name}>
|
||||||
<button
|
<div className="flex items-center" style={{ paddingLeft: `${depth * 12}px` }}>
|
||||||
onClick={() => onSelect(folder.id)}
|
{hasChildren ? (
|
||||||
className={clsx(
|
<button
|
||||||
'w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm min-h-touch',
|
onClick={(e) => {
|
||||||
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
e.stopPropagation();
|
||||||
isSelected
|
setExpanded(!expanded);
|
||||||
? 'bg-primary-50 text-primary-700 font-medium'
|
}}
|
||||||
: 'hover:bg-secondary-50 text-secondary-700',
|
className="flex-shrink-0 p-0.5 hover:bg-secondary-100 rounded"
|
||||||
|
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||||
|
data-testid={`folder-chevron-${folder.id}`}
|
||||||
|
>
|
||||||
|
<ChevronIcon expanded={expanded} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="w-4 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
data-testid={`folder-${folder.id}`}
|
<button
|
||||||
>
|
onClick={() => onSelect(folder.id)}
|
||||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
className={clsx(
|
||||||
<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" />
|
'flex-1 flex items-center gap-2 px-2 py-1.5 rounded-md text-sm min-h-touch',
|
||||||
</svg>
|
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||||
<span className="flex-1 truncate text-left">{folder.name}</span>
|
isSelected
|
||||||
{folder.unread_count > 0 && (
|
? 'bg-primary-50 text-primary-700 font-medium'
|
||||||
<Badge variant="primary" className="text-xs">{folder.unread_count}</Badge>
|
: 'hover:bg-secondary-50 text-secondary-700',
|
||||||
)}
|
)}
|
||||||
{folder.total_count > 0 && folder.unread_count === 0 && (
|
data-testid={`folder-${folder.id}`}
|
||||||
<span className="text-xs text-secondary-400">{folder.total_count}</span>
|
>
|
||||||
)}
|
<FolderIcon />
|
||||||
</button>
|
<span className="flex-1 truncate text-left">{folder.name}</span>
|
||||||
{hasChildren && (
|
{folder.unread_count > 0 && (
|
||||||
<div className="ml-4" role="group">
|
<Badge variant="primary" className="text-xs">{folder.unread_count}</Badge>
|
||||||
|
)}
|
||||||
|
{folder.total_count > 0 && folder.unread_count === 0 && (
|
||||||
|
<span className="text-xs text-secondary-400">{folder.total_count}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{hasChildren && expanded && (
|
||||||
|
<div role="group">
|
||||||
{folder.children!.map((child) => (
|
{folder.children!.map((child) => (
|
||||||
<FolderNode
|
<FolderNode
|
||||||
key={child.id}
|
key={child.id}
|
||||||
@@ -73,7 +145,52 @@ function FolderNode({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MailFolderTree({ folders, selectedFolderId, onSelect, loading }: MailFolderTreeProps) {
|
function AccountNode({
|
||||||
|
account,
|
||||||
|
folders,
|
||||||
|
selectedFolderId,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
account: MailAccount;
|
||||||
|
folders: MailFolder[];
|
||||||
|
selectedFolderId: string | null;
|
||||||
|
onSelect: (folderId: string) => void;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
const accountFolders = useMemo(
|
||||||
|
() => buildFolderTree(folders, account.id),
|
||||||
|
[folders, account.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="treeitem" aria-label={account.email}>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="w-full flex items-center gap-1 px-1 py-1.5 rounded-md text-sm font-semibold text-secondary-900 hover:bg-secondary-50"
|
||||||
|
data-testid={`account-${account.id}`}
|
||||||
|
>
|
||||||
|
<ChevronIcon expanded={expanded} />
|
||||||
|
<MailIcon />
|
||||||
|
<span className="flex-1 truncate text-left">{account.email}</span>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div role="group" className="ml-2">
|
||||||
|
{accountFolders.map((folder) => (
|
||||||
|
<FolderNode
|
||||||
|
key={folder.id}
|
||||||
|
folder={folder}
|
||||||
|
selectedFolderId={selectedFolderId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
depth={0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MailFolderTree({ accounts, folders, selectedFolderId, onSelect, loading }: MailFolderTreeProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -88,7 +205,7 @@ export function MailFolderTree({ folders, selectedFolderId, onSelect, loading }:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (folders.length === 0) {
|
if (accounts.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={t('mail.noFolders')}
|
title={t('mail.noFolders')}
|
||||||
@@ -100,13 +217,13 @@ export function MailFolderTree({ folders, selectedFolderId, onSelect, loading }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1" role="tree" data-testid="folder-tree-list">
|
<div className="space-y-1" role="tree" data-testid="folder-tree-list">
|
||||||
{folders.map((folder) => (
|
{accounts.map((account) => (
|
||||||
<FolderNode
|
<AccountNode
|
||||||
key={folder.id}
|
key={account.id}
|
||||||
folder={folder}
|
account={account}
|
||||||
|
folders={folders}
|
||||||
selectedFolderId={selectedFolderId}
|
selectedFolderId={selectedFolderId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
depth={0}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* ResizablePanel — a flex panel with drag-to-resize handle.
|
||||||
|
* No external dependencies; uses React + mouse events only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export interface ResizablePanelProps {
|
||||||
|
/** Initial width in pixels (only used when resizable=true) */
|
||||||
|
initialWidth?: number;
|
||||||
|
/** Minimum width in pixels */
|
||||||
|
minWidth?: number;
|
||||||
|
/** Maximum width in pixels */
|
||||||
|
maxWidth?: number;
|
||||||
|
/** Panel content */
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Extra className for the panel container */
|
||||||
|
className?: string;
|
||||||
|
/** When false, panel becomes flex-1 with no drag handle */
|
||||||
|
resizable?: boolean;
|
||||||
|
/** testid forwarded to the container div */
|
||||||
|
'data-testid'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResizablePanel({
|
||||||
|
initialWidth = 224,
|
||||||
|
minWidth = 150,
|
||||||
|
maxWidth = 600,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
resizable = true,
|
||||||
|
...rest
|
||||||
|
}: ResizablePanelProps) {
|
||||||
|
const [width, setWidth] = useState(initialWidth);
|
||||||
|
const isResizing = useRef(false);
|
||||||
|
const startX = useRef(0);
|
||||||
|
const startWidth = useRef(0);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
isResizing.current = true;
|
||||||
|
startX.current = e.clientX;
|
||||||
|
startWidth.current = width;
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
},
|
||||||
|
[width],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!isResizing.current) return;
|
||||||
|
const delta = e.clientX - startX.current;
|
||||||
|
const newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth.current + delta));
|
||||||
|
setWidth(newWidth);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (isResizing.current) {
|
||||||
|
isResizing.current = false;
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [minWidth, maxWidth]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(resizable ? 'relative flex-shrink-0' : 'relative flex-1', className)}
|
||||||
|
style={resizable ? { width: `${width}px` } : undefined}
|
||||||
|
data-testid={rest['data-testid']}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{resizable && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 right-0 h-full w-1 cursor-col-resize bg-transparent hover:bg-primary-300 active:bg-primary-400 transition-colors z-10"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
data-testid="resize-handle"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+198
-136
@@ -1,5 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Mail page — folder tree + mail list + reading pane + compose.
|
* Mail page — folder tree + mail list + reading pane + compose.
|
||||||
|
* Uses ResizablePanel for drag-to-resize columns.
|
||||||
|
* Account selection is integrated into the folder tree (left pane).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
@@ -9,6 +11,7 @@ import { Card } from '@/components/ui/Card';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { useToast } from '@/components/ui/Toast';
|
import { useToast } from '@/components/ui/Toast';
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
||||||
import { MailFolderTree } from '@/components/mail/MailFolderTree';
|
import { MailFolderTree } from '@/components/mail/MailFolderTree';
|
||||||
import { MailList } from '@/components/mail/MailList';
|
import { MailList } from '@/components/mail/MailList';
|
||||||
import { MailDetail } from '@/components/mail/MailDetail';
|
import { MailDetail } from '@/components/mail/MailDetail';
|
||||||
@@ -89,7 +92,7 @@ export function MailPage() {
|
|||||||
const sigs = await fetchSignatures();
|
const sigs = await fetchSignatures();
|
||||||
setSignatures(sigs);
|
setSignatures(sigs);
|
||||||
} catch {
|
} catch {
|
||||||
// non-critical
|
// non-critical
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -98,27 +101,35 @@ export function MailPage() {
|
|||||||
loadSignatures();
|
loadSignatures();
|
||||||
}, [loadAccounts, loadSignatures]);
|
}, [loadAccounts, loadSignatures]);
|
||||||
|
|
||||||
// Load folders when account changes
|
// Load folders for ALL accounts
|
||||||
const loadFolders = useCallback(async () => {
|
const loadAllFolders = useCallback(async () => {
|
||||||
if (!selectedAccountId) return;
|
if (accounts.length === 0) return;
|
||||||
setLoadingFolders(true);
|
setLoadingFolders(true);
|
||||||
try {
|
try {
|
||||||
const folderList = await fetchFolders(selectedAccountId);
|
const allFolders: MailFolder[] = [];
|
||||||
setFolders(folderList);
|
for (const acc of accounts) {
|
||||||
// Auto-select first folder
|
const folderList = await fetchFolders(acc.id);
|
||||||
if (folderList.length > 0 && !selectedFolderId) {
|
allFolders.push(...folderList);
|
||||||
setSelectedFolderId(folderList[0].id);
|
|
||||||
}
|
}
|
||||||
|
setFolders(allFolders);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
setError(msg);
|
setError(msg);
|
||||||
}
|
}
|
||||||
setLoadingFolders(false);
|
setLoadingFolders(false);
|
||||||
}, [selectedAccountId, selectedFolderId]);
|
}, [accounts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFolders();
|
loadAllFolders();
|
||||||
}, [loadFolders]);
|
}, [loadAllFolders]);
|
||||||
|
|
||||||
|
// Auto-select first folder when folders are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (folders.length > 0 && !selectedFolderId) {
|
||||||
|
setSelectedFolderId(folders[0].id);
|
||||||
|
setSelectedAccountId(folders[0].account_id);
|
||||||
|
}
|
||||||
|
}, [folders, selectedFolderId]);
|
||||||
|
|
||||||
// Load mails when folder or page changes
|
// Load mails when folder or page changes
|
||||||
const loadMails = useCallback(async () => {
|
const loadMails = useCallback(async () => {
|
||||||
@@ -146,32 +157,42 @@ export function MailPage() {
|
|||||||
loadMails();
|
loadMails();
|
||||||
}, [loadMails]);
|
}, [loadMails]);
|
||||||
|
|
||||||
// Handle folder selection
|
// Handle folder selection — derive account from folder
|
||||||
const handleSelectFolder = useCallback((folderId: string) => {
|
const handleSelectFolder = useCallback(
|
||||||
setSelectedFolderId(folderId);
|
(folderId: string) => {
|
||||||
setMailsPage(1);
|
setSelectedFolderId(folderId);
|
||||||
setSearchQuery('');
|
const folder = folders.find((f) => f.id === folderId);
|
||||||
setSelectedMail(null);
|
if (folder) {
|
||||||
}, []);
|
setSelectedAccountId(folder.account_id);
|
||||||
|
}
|
||||||
|
setMailsPage(1);
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedMail(null);
|
||||||
|
},
|
||||||
|
[folders],
|
||||||
|
);
|
||||||
|
|
||||||
// Handle mail selection
|
// Handle mail selection
|
||||||
const handleSelectMail = useCallback(async (mail: Mail) => {
|
const handleSelectMail = useCallback(
|
||||||
setLoadingMail(true);
|
async (mail: Mail) => {
|
||||||
try {
|
setLoadingMail(true);
|
||||||
const detail = await getMail(mail.id);
|
try {
|
||||||
setSelectedMail(detail);
|
const detail = await getMail(mail.id);
|
||||||
// Mark as seen if not seen
|
setSelectedMail(detail);
|
||||||
if (!detail.is_seen) {
|
// Mark as seen if not seen
|
||||||
await updateFlags(mail.id, { seen: true });
|
if (!detail.is_seen) {
|
||||||
setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_seen: true } : m)));
|
await updateFlags(mail.id, { seen: true });
|
||||||
|
setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_seen: true } : m)));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
toast.error(msg);
|
||||||
|
setSelectedMail(mail);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
setLoadingMail(false);
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
},
|
||||||
toast.error(msg);
|
[toast],
|
||||||
setSelectedMail(mail);
|
);
|
||||||
}
|
|
||||||
setLoadingMail(false);
|
|
||||||
}, [toast]);
|
|
||||||
|
|
||||||
// Handle compose
|
// Handle compose
|
||||||
const handleCompose = useCallback(() => {
|
const handleCompose = useCallback(() => {
|
||||||
@@ -198,72 +219,84 @@ export function MailPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle toggle flag
|
// Handle toggle flag
|
||||||
const handleToggleFlag = useCallback(async (mail: Mail) => {
|
const handleToggleFlag = useCallback(
|
||||||
try {
|
async (mail: Mail) => {
|
||||||
await updateFlags(mail.id, { flagged: !mail.is_flagged });
|
try {
|
||||||
setSelectedMail((prev) => prev ? { ...prev, is_flagged: !mail.is_flagged } : prev);
|
await updateFlags(mail.id, { flagged: !mail.is_flagged });
|
||||||
setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_flagged: !mail.is_flagged } : m)));
|
setSelectedMail((prev) => (prev ? { ...prev, is_flagged: !mail.is_flagged } : prev));
|
||||||
} catch (err) {
|
setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_flagged: !mail.is_flagged } : m)));
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
} catch (err) {
|
||||||
}
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
}, [toast]);
|
}
|
||||||
|
},
|
||||||
|
[toast],
|
||||||
|
);
|
||||||
|
|
||||||
// Handle create event from mail
|
// Handle create event from mail
|
||||||
const handleCreateEvent = useCallback(async (mail: Mail) => {
|
const handleCreateEvent = useCallback(
|
||||||
const title = mail.subject || t('mail.noSubject');
|
async (mail: Mail) => {
|
||||||
const now = new Date();
|
const title = mail.subject || t('mail.noSubject');
|
||||||
const start = now.toISOString();
|
const now = new Date();
|
||||||
const end = new Date(now.getTime() + 60 * 60 * 1000).toISOString();
|
const start = now.toISOString();
|
||||||
const payload: CreateEventFromMailPayload = {
|
const end = new Date(now.getTime() + 60 * 60 * 1000).toISOString();
|
||||||
title,
|
const payload: CreateEventFromMailPayload = {
|
||||||
start,
|
title,
|
||||||
end,
|
start,
|
||||||
description: mail.body_text.slice(0, 500),
|
end,
|
||||||
};
|
description: mail.body_text.slice(0, 500),
|
||||||
try {
|
};
|
||||||
await createEventFromMail(mail.id, payload);
|
try {
|
||||||
toast.success(t('mail.eventCreated'));
|
await createEventFromMail(mail.id, payload);
|
||||||
} catch (err) {
|
toast.success(t('mail.eventCreated'));
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
} catch (err) {
|
||||||
}
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
}, [toast, t]);
|
}
|
||||||
|
},
|
||||||
|
[toast, t],
|
||||||
|
);
|
||||||
|
|
||||||
// Handle attachment download
|
// Handle attachment download
|
||||||
const handleDownloadAttachment = useCallback(async (mailId: string, attachment: MailAttachment) => {
|
const handleDownloadAttachment = useCallback(
|
||||||
setDownloadingAttachmentId(attachment.id);
|
async (mailId: string, attachment: MailAttachment) => {
|
||||||
try {
|
setDownloadingAttachmentId(attachment.id);
|
||||||
const blob = await downloadAttachment(mailId, attachment.id);
|
try {
|
||||||
const url = window.URL.createObjectURL(blob);
|
const blob = await downloadAttachment(mailId, attachment.id);
|
||||||
const a = window.document.createElement('a');
|
const url = window.URL.createObjectURL(blob);
|
||||||
a.href = url;
|
const a = window.document.createElement('a');
|
||||||
a.download = attachment.filename;
|
a.href = url;
|
||||||
window.document.body.appendChild(a);
|
a.download = attachment.filename;
|
||||||
a.click();
|
window.document.body.appendChild(a);
|
||||||
window.document.body.removeChild(a);
|
a.click();
|
||||||
window.URL.revokeObjectURL(url);
|
window.document.body.removeChild(a);
|
||||||
} catch (err) {
|
window.URL.revokeObjectURL(url);
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
} catch (err) {
|
||||||
} finally {
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
setDownloadingAttachmentId(null);
|
} finally {
|
||||||
}
|
setDownloadingAttachmentId(null);
|
||||||
}, [toast]);
|
}
|
||||||
|
},
|
||||||
|
[toast],
|
||||||
|
);
|
||||||
|
|
||||||
// Handle send (compose)
|
// Handle send (compose)
|
||||||
const handleSend = useCallback(async (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => {
|
const handleSend = useCallback(
|
||||||
try {
|
async (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => {
|
||||||
if (mode === 'reply' && replyToMail) {
|
try {
|
||||||
await replyMail(replyToMail.id, payload as ReplyPayload);
|
if (mode === 'reply' && replyToMail) {
|
||||||
} else if (mode === 'forward' && forwardMailState) {
|
await replyMail(replyToMail.id, payload as ReplyPayload);
|
||||||
await forwardMail(forwardMailState.id, payload as ForwardPayload);
|
} else if (mode === 'forward' && forwardMailState) {
|
||||||
} else {
|
await forwardMail(forwardMailState.id, payload as ForwardPayload);
|
||||||
await sendMail(payload as SendMailPayload);
|
} else {
|
||||||
|
await sendMail(payload as SendMailPayload);
|
||||||
|
}
|
||||||
|
toast.success(t('mail.sent'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
toast.success(t('mail.sent'));
|
},
|
||||||
} catch (err) {
|
[replyToMail, forwardMailState, toast, t],
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
);
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, [replyToMail, forwardMailState, toast, t]);
|
|
||||||
|
|
||||||
// Handle search
|
// Handle search
|
||||||
const handleSearch = useCallback((query: string) => {
|
const handleSearch = useCallback((query: string) => {
|
||||||
@@ -271,17 +304,7 @@ export function MailPage() {
|
|||||||
setMailsPage(1);
|
setMailsPage(1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle account switch
|
// Register toolbar items (account-select removed — account is now in folder tree)
|
||||||
const handleAccountSwitch = useCallback((accountId: string) => {
|
|
||||||
setSelectedAccountId(accountId);
|
|
||||||
setSelectedFolderId(null);
|
|
||||||
setSelectedMail(null);
|
|
||||||
setMails([]);
|
|
||||||
setMailsPage(1);
|
|
||||||
setSearchQuery('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Register toolbar items
|
|
||||||
const registerItems = usePluginToolbarStore((s) => s.registerItems);
|
const registerItems = usePluginToolbarStore((s) => s.registerItems);
|
||||||
const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin);
|
const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin);
|
||||||
const selectedMailId = selectedMail?.id;
|
const selectedMailId = selectedMail?.id;
|
||||||
@@ -289,16 +312,6 @@ export function MailPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasMail = !!selectedMailId;
|
const hasMail = !!selectedMailId;
|
||||||
registerItems('mail', [
|
registerItems('mail', [
|
||||||
{
|
|
||||||
id: 'account-select',
|
|
||||||
plugin: 'mail',
|
|
||||||
label: 'Account',
|
|
||||||
type: 'select',
|
|
||||||
group: 'account',
|
|
||||||
selectValue: selectedAccountId,
|
|
||||||
selectOptions: accounts.map((a) => ({ value: a.id, label: a.email })),
|
|
||||||
onSelect: handleAccountSwitch,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'search',
|
id: 'search',
|
||||||
plugin: 'mail',
|
plugin: 'mail',
|
||||||
@@ -307,13 +320,18 @@ export function MailPage() {
|
|||||||
group: 'search',
|
group: 'search',
|
||||||
searchPlaceholder: 'E-Mails durchsuchen...',
|
searchPlaceholder: 'E-Mails durchsuchen...',
|
||||||
onSearch: handleSearch,
|
onSearch: handleSearch,
|
||||||
|
onClick: () => {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'compose',
|
id: 'compose',
|
||||||
plugin: 'mail',
|
plugin: 'mail',
|
||||||
label: 'Verfassen',
|
label: 'Verfassen',
|
||||||
group: 'compose',
|
group: 'compose',
|
||||||
icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>,
|
icon: (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
onClick: handleCompose,
|
onClick: handleCompose,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -322,7 +340,11 @@ export function MailPage() {
|
|||||||
label: 'Antworten',
|
label: 'Antworten',
|
||||||
group: 'mail-actions',
|
group: 'mail-actions',
|
||||||
disabled: !hasMail,
|
disabled: !hasMail,
|
||||||
icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" /></svg>,
|
icon: (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
onClick: () => selectedMail && handleReply(selectedMail),
|
onClick: () => selectedMail && handleReply(selectedMail),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -331,7 +353,11 @@ export function MailPage() {
|
|||||||
label: 'Weiterleiten',
|
label: 'Weiterleiten',
|
||||||
group: 'mail-actions',
|
group: 'mail-actions',
|
||||||
disabled: !hasMail,
|
disabled: !hasMail,
|
||||||
icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10H11a8 8 0 00-8 8v2m18-10l-6 6m6-6l-6-6" /></svg>,
|
icon: (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10H11a8 8 0 00-8 8v2m18-10l-6 6m6-6l-6-6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
onClick: () => selectedMail && handleForward(selectedMail),
|
onClick: () => selectedMail && handleForward(selectedMail),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -341,7 +367,11 @@ export function MailPage() {
|
|||||||
group: 'mail-actions',
|
group: 'mail-actions',
|
||||||
disabled: !hasMail,
|
disabled: !hasMail,
|
||||||
active: selectedMailFlagged,
|
active: selectedMailFlagged,
|
||||||
icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" /></svg>,
|
icon: (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
onClick: () => selectedMail && handleToggleFlag(selectedMail),
|
onClick: () => selectedMail && handleToggleFlag(selectedMail),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -350,7 +380,11 @@ export function MailPage() {
|
|||||||
label: 'Termin',
|
label: 'Termin',
|
||||||
group: 'mail-actions',
|
group: 'mail-actions',
|
||||||
disabled: !hasMail,
|
disabled: !hasMail,
|
||||||
icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>,
|
icon: (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
onClick: () => selectedMail && handleCreateEvent(selectedMail),
|
onClick: () => selectedMail && handleCreateEvent(selectedMail),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -358,7 +392,11 @@ export function MailPage() {
|
|||||||
plugin: 'mail',
|
plugin: 'mail',
|
||||||
label: 'Sync',
|
label: 'Sync',
|
||||||
group: 'tools',
|
group: 'tools',
|
||||||
icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>,
|
icon: (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
onClick: () => { /* sync trigger */ },
|
onClick: () => { /* sync trigger */ },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -366,12 +404,19 @@ export function MailPage() {
|
|||||||
plugin: 'mail',
|
plugin: 'mail',
|
||||||
label: 'Einstellungen',
|
label: 'Einstellungen',
|
||||||
group: 'tools',
|
group: 'tools',
|
||||||
icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>,
|
icon: (
|
||||||
onClick: () => window.location.href = '/mail/settings',
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
onClick: () => {
|
||||||
|
window.location.href = '/settings/mail';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
return () => unregisterPlugin('mail');
|
return () => unregisterPlugin('mail');
|
||||||
}, [accounts, selectedAccountId, selectedMailId, selectedMailFlagged, handleAccountSwitch, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent]);
|
}, [selectedMailId, selectedMailFlagged, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent]);
|
||||||
|
|
||||||
if (loadingAccounts) {
|
if (loadingAccounts) {
|
||||||
return (
|
return (
|
||||||
@@ -391,7 +436,7 @@ export function MailPage() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
title={t('mail.noAccounts')}
|
title={t('mail.noAccounts')}
|
||||||
description={t('mail.noAccountsDesc')}
|
description={t('mail.noAccountsDesc')}
|
||||||
action={<Link to="/mail/settings" className="text-primary-600 hover:text-primary-700">{t('mail.configureAccount')}</Link>}
|
action={<Link to="/settings/mail" className="text-primary-600 hover:text-primary-700">{t('mail.configureAccount')}</Link>}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -405,23 +450,36 @@ export function MailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Three-pane layout */}
|
{/* Three-pane layout with resizable panels */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Folder tree */}
|
{/* Folder tree — resizable */}
|
||||||
<div className="w-56 border-r border-secondary-200 overflow-y-auto bg-white" data-testid="mail-folder-pane">
|
<ResizablePanel
|
||||||
|
initialWidth={224}
|
||||||
|
minWidth={150}
|
||||||
|
maxWidth={400}
|
||||||
|
className="border-r border-secondary-200 overflow-y-auto bg-white"
|
||||||
|
data-testid="mail-folder-pane"
|
||||||
|
>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<h3 className="text-xs font-semibold text-secondary-500 uppercase mb-2">{t('mail.folders')}</h3>
|
<h3 className="text-xs font-semibold text-secondary-500 uppercase mb-2">{t('mail.folders')}</h3>
|
||||||
<MailFolderTree
|
<MailFolderTree
|
||||||
|
accounts={accounts}
|
||||||
folders={folders}
|
folders={folders}
|
||||||
selectedFolderId={selectedFolderId}
|
selectedFolderId={selectedFolderId}
|
||||||
onSelect={handleSelectFolder}
|
onSelect={handleSelectFolder}
|
||||||
loading={loadingFolders}
|
loading={loadingFolders}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ResizablePanel>
|
||||||
|
|
||||||
{/* Mail list */}
|
{/* Mail list — resizable */}
|
||||||
<div className="w-80 border-r border-secondary-200 overflow-y-auto bg-white" data-testid="mail-list-pane">
|
<ResizablePanel
|
||||||
|
initialWidth={320}
|
||||||
|
minWidth={200}
|
||||||
|
maxWidth={500}
|
||||||
|
className="border-r border-secondary-200 overflow-y-auto bg-white"
|
||||||
|
data-testid="mail-list-pane"
|
||||||
|
>
|
||||||
<MailList
|
<MailList
|
||||||
mails={mails}
|
mails={mails}
|
||||||
selectedMailId={selectedMail?.id || null}
|
selectedMailId={selectedMail?.id || null}
|
||||||
@@ -432,10 +490,14 @@ export function MailPage() {
|
|||||||
pageSize={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
onPageChange={setMailsPage}
|
onPageChange={setMailsPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ResizablePanel>
|
||||||
|
|
||||||
{/* Reading pane */}
|
{/* Reading pane — flex-1, not resizable */}
|
||||||
<div className="flex-1 overflow-y-auto bg-white" data-testid="mail-detail-pane">
|
<ResizablePanel
|
||||||
|
resizable={false}
|
||||||
|
className="overflow-y-auto bg-white"
|
||||||
|
data-testid="mail-detail-pane"
|
||||||
|
>
|
||||||
<MailDetail
|
<MailDetail
|
||||||
mail={selectedMail}
|
mail={selectedMail}
|
||||||
loading={loadingMail}
|
loading={loadingMail}
|
||||||
@@ -446,7 +508,7 @@ export function MailPage() {
|
|||||||
onDownloadAttachment={handleDownloadAttachment}
|
onDownloadAttachment={handleDownloadAttachment}
|
||||||
downloadingAttachmentId={downloadingAttachmentId}
|
downloadingAttachmentId={downloadingAttachmentId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ResizablePanel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ComposeModal
|
<ComposeModal
|
||||||
@@ -461,4 +523,4 @@ export function MailPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function SettingsPage() {
|
|||||||
{ to: '/settings/currencies', label: t('currencies.title'), icon: '\ud83d\udcb0' },
|
{ to: '/settings/currencies', label: t('currencies.title'), icon: '\ud83d\udcb0' },
|
||||||
{ to: '/settings/taxes', label: t('taxes.title'), icon: '\ud83d\udccb' },
|
{ to: '/settings/taxes', label: t('taxes.title'), icon: '\ud83d\udccb' },
|
||||||
{ to: '/settings/sequences', label: t('sequences.title'), icon: '\ud83d\udd22' },
|
{ to: '/settings/sequences', label: t('sequences.title'), icon: '\ud83d\udd22' },
|
||||||
|
{ to: '/settings/mail', label: t('mail.settings'), icon: '\ud83d\udce7' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'currencies', element: <SettingsCurrenciesPage /> },
|
{ path: 'currencies', element: <SettingsCurrenciesPage /> },
|
||||||
{ path: 'taxes', element: <SettingsTaxesPage /> },
|
{ path: 'taxes', element: <SettingsTaxesPage /> },
|
||||||
{ path: 'sequences', element: <SettingsSequencesPage /> },
|
{ path: 'sequences', element: <SettingsSequencesPage /> },
|
||||||
|
{ path: 'mail', element: <MailSettingsPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user