T08c: Frontend Mail UI + Global Search UI — 44 tests, tsc clean, vite build pass
- Mail page: 3-pane layout (folder tree + mail list + reading pane) - Compose modal: rich text editor (bold/italic/link), template picker, reply/forward pre-fill - Mail settings: accounts, signatures, rules, labels, vacation, PGP (6 tabs) - Shared mailbox selector: switch between personal + shared accounts - Mail search bar + attachment download + create-event-from-mail - Global search: tabs for companies/contacts/mails/files/events - Search autocomplete in TopBar (existing SearchDropdown) - API client: mail.ts (all endpoints) - Routes: /mail, /mail/settings - i18n: de.json + en.json mail + search translations - 44 new tests (4 test files), full regression 318/318 pass - tsc --noEmit: 0 errors, vite build: 267 modules
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Compose modal — rich text editor with toolbar (bold, italic, link, template insert).
|
||||
* Used for new mail, reply, and forward.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { TemplatePicker } from './TemplatePicker';
|
||||
import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload } from '@/api/mail';
|
||||
|
||||
export type ComposeMode = 'new' | 'reply' | 'forward';
|
||||
|
||||
export interface ComposeModalProps {
|
||||
open: boolean;
|
||||
mode: ComposeMode;
|
||||
accountId: string;
|
||||
replyToMail: Mail | null;
|
||||
forwardMail: Mail | null;
|
||||
signatures: MailSignature[];
|
||||
onSend: (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ComposeModal({
|
||||
open,
|
||||
mode,
|
||||
accountId,
|
||||
replyToMail,
|
||||
forwardMail,
|
||||
signatures,
|
||||
onSend,
|
||||
onClose,
|
||||
}: ComposeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [to, setTo] = useState('');
|
||||
const [cc, setCc] = useState('');
|
||||
const [bcc, setBcc] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [showCc, setShowCc] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [selectedSignatureId, setSelectedSignatureId] = useState('');
|
||||
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (mode === 'reply' && replyToMail) {
|
||||
setTo(replyToMail.from_address);
|
||||
setSubject(replyToMail.subject.startsWith('Re: ') ? replyToMail.subject : `Re: ${replyToMail.subject}`);
|
||||
setBody(`\n\n---\n${replyToMail.body_text.slice(0, 200)}`);
|
||||
} else if (mode === 'forward' && forwardMail) {
|
||||
setTo('');
|
||||
setSubject(forwardMail.subject.startsWith('Fwd: ') ? forwardMail.subject : `Fwd: ${forwardMail.subject}`);
|
||||
setBody(`\n\n---\n${t('mail.forwarding')}\n${t('mail.from')}: ${forwardMail.from_address}\n${t('mail.subject')}: ${forwardMail.subject}\n\n${forwardMail.body_text.slice(0, 200)}`);
|
||||
} else {
|
||||
setTo('');
|
||||
setCc('');
|
||||
setBcc('');
|
||||
setSubject('');
|
||||
setBody('');
|
||||
}
|
||||
}, [open, mode, replyToMail, forwardMail, t]);
|
||||
|
||||
const execCommand = useCallback((command: string, value?: string) => {
|
||||
document.execCommand(command, false, value);
|
||||
if (editorRef.current) {
|
||||
setBody(editorRef.current.innerHTML);
|
||||
}
|
||||
editorRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
const url = window.prompt(t('mail.linkUrl'));
|
||||
if (url) {
|
||||
execCommand('createLink', url);
|
||||
}
|
||||
}, [execCommand, t]);
|
||||
|
||||
const insertSignature = useCallback((signatureId: string) => {
|
||||
setSelectedSignatureId(signatureId);
|
||||
const sig = signatures.find((s) => s.id === signatureId);
|
||||
if (sig && editorRef.current) {
|
||||
const current = editorRef.current.innerHTML;
|
||||
editorRef.current.innerHTML = `${current}<br/><br/>---<br/>${sig.body_html}`;
|
||||
setBody(editorRef.current.innerHTML);
|
||||
}
|
||||
}, [signatures]);
|
||||
|
||||
const handleTemplateSelect = useCallback((templateBody: string, templateSubject: string) => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.innerHTML = templateBody;
|
||||
setBody(templateBody);
|
||||
}
|
||||
if (templateSubject) {
|
||||
setSubject(templateSubject);
|
||||
}
|
||||
setShowTemplatePicker(false);
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!to.trim()) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const toList = to.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const ccList = cc ? cc.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
const bccList = bcc ? bcc.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
|
||||
if (mode === 'reply' && replyToMail) {
|
||||
const replyPayload: ReplyPayload = {
|
||||
account_id: accountId,
|
||||
body,
|
||||
is_html: true,
|
||||
to: toList,
|
||||
cc: ccList,
|
||||
signature_id: selectedSignatureId || null,
|
||||
};
|
||||
await onSend(replyPayload, 'reply');
|
||||
} else if (mode === 'forward' && forwardMail) {
|
||||
const fwdPayload: ForwardPayload = {
|
||||
account_id: accountId,
|
||||
to: toList,
|
||||
body,
|
||||
is_html: true,
|
||||
signature_id: selectedSignatureId || null,
|
||||
};
|
||||
await onSend(fwdPayload, 'forward');
|
||||
} else {
|
||||
const sendPayload: SendMailPayload = {
|
||||
account_id: accountId,
|
||||
to: toList,
|
||||
cc: ccList,
|
||||
bcc: bccList,
|
||||
subject,
|
||||
body,
|
||||
is_html: true,
|
||||
signature_id: selectedSignatureId || null,
|
||||
};
|
||||
await onSend(sendPayload, 'new');
|
||||
}
|
||||
onClose();
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [to, cc, bcc, subject, body, mode, replyToMail, forwardMail, accountId, selectedSignatureId, onSend, onClose]);
|
||||
|
||||
const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : t('mail.compose');
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={title} size="xl" closeOnBackdrop={false}>
|
||||
<div className="space-y-4" data-testid="compose-modal">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 border-b border-secondary-200 pb-2" data-testid="compose-toolbar">
|
||||
<button
|
||||
onClick={() => execCommand('bold')}
|
||||
className="p-2 rounded hover:bg-secondary-100 min-h-touch min-w-touch"
|
||||
aria-label={t('mail.bold')}
|
||||
title={t('mail.bold')}
|
||||
type="button"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6V4z M6 12h9a4 4 0 014 4 4 4 0 01-4 4H6v-8z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => execCommand('italic')}
|
||||
className="p-2 rounded hover:bg-secondary-100 min-h-touch min-w-touch"
|
||||
aria-label={t('mail.italic')}
|
||||
title={t('mail.italic')}
|
||||
type="button"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 4l-7 16M10 4L3 20M4 8h6M14 8h6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={insertLink}
|
||||
className="p-2 rounded hover:bg-secondary-100 min-h-touch min-w-touch"
|
||||
aria-label={t('mail.insertLink')}
|
||||
title={t('mail.insertLink')}
|
||||
type="button"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="w-px h-6 bg-secondary-200 mx-1" />
|
||||
<button
|
||||
onClick={() => setShowTemplatePicker(!showTemplatePicker)}
|
||||
className="px-3 py-2 rounded hover:bg-secondary-100 text-sm min-h-touch"
|
||||
aria-label={t('mail.insertTemplate')}
|
||||
title={t('mail.insertTemplate')}
|
||||
type="button"
|
||||
data-testid="template-picker-toggle"
|
||||
>
|
||||
{t('mail.template')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showTemplatePicker && (
|
||||
<TemplatePicker onSelect={handleTemplateSelect} />
|
||||
)}
|
||||
|
||||
{/* Recipients */}
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
label={t('mail.to')}
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
placeholder="recipient@example.com"
|
||||
required
|
||||
data-testid="compose-to"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{!showCc ? (
|
||||
<button
|
||||
onClick={() => setShowCc(true)}
|
||||
className="text-sm text-primary-600 hover:text-primary-700"
|
||||
type="button"
|
||||
>
|
||||
{t('mail.showCcBcc')}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<Input
|
||||
label={t('mail.cc')}
|
||||
value={cc}
|
||||
onChange={(e) => setCc(e.target.value)}
|
||||
placeholder="cc@example.com"
|
||||
/>
|
||||
<Input
|
||||
label={t('mail.bcc')}
|
||||
value={bcc}
|
||||
onChange={(e) => setBcc(e.target.value)}
|
||||
placeholder="bcc@example.com"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
label={t('mail.subject')}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={t('mail.subjectPlaceholder')}
|
||||
data-testid="compose-subject"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.body')}</label>
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
onInput={(e) => setBody((e.target as HTMLDivElement).innerHTML)}
|
||||
className="min-h-48 border border-secondary-300 rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-primary-500 overflow-y-auto"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={t('mail.body')}
|
||||
data-testid="compose-editor"
|
||||
dangerouslySetInnerHTML={mode === 'reply' && replyToMail ? { __html: body } : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Signature */}
|
||||
<Select
|
||||
label={t('mail.signature')}
|
||||
value={selectedSignatureId}
|
||||
onChange={(e) => insertSignature(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: t('mail.noSignature') },
|
||||
...signatures.map((sig) => ({ value: sig.id, label: sig.name })),
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-secondary-200">
|
||||
<Button variant="secondary" onClick={onClose} type="button">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSend} isLoading={sending} type="button" data-testid="compose-send">
|
||||
{t('mail.send')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user