feat: add visual WYSIWYG email template editor (#229)

Replace raw HTML textarea with TipTap-based rich text editor for email
templates. Includes formatting toolbar, variable insertion dropdown,
source/visual toggle, and dark mode support. Add Mailhog service to
docker-compose for local email testing.
This commit is contained in:
Paul Nothaft
2026-03-15 22:05:27 +01:00
parent c0a5cd56c8
commit 04a7ea80f9
6 changed files with 480 additions and 30 deletions
+10
View File
@@ -98,6 +98,16 @@ services:
networks:
- picpeak-network
mailhog:
image: mailhog/mailhog:latest
container_name: picpeak-mailhog
restart: unless-stopped
ports:
- "${MAILHOG_SMTP_PORT:-1025}:1025"
- "${MAILHOG_UI_PORT:-8025}:8025"
networks:
- picpeak-network
frontend:
build:
context: ./frontend
@@ -0,0 +1,398 @@
import React, { useState, useCallback } from 'react';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import HardBreak from '@tiptap/extension-hard-break';
import TextAlign from '@tiptap/extension-text-align';
import {
Bold,
Italic,
List,
ListOrdered,
Link as LinkIcon,
Heading2,
Heading3,
Quote,
Minus,
Undo,
Redo,
RemoveFormatting,
AlignLeft,
AlignCenter,
AlignRight,
Code2,
Variable,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface EmailTemplateEditorProps {
content: string;
onChange: (content: string) => void;
variables?: string[];
}
export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
content,
onChange,
variables = [],
}) => {
const { t } = useTranslation();
const [isSourceMode, setIsSourceMode] = useState(false);
const [sourceContent, setSourceContent] = useState(content);
const [linkUrl, setLinkUrl] = useState('');
const [showLinkDialog, setShowLinkDialog] = useState(false);
const [showVariables, setShowVariables] = useState(false);
const editor = useEditor({
extensions: [
StarterKit.configure({
hardBreak: false,
}),
HardBreak.configure({
keepMarks: true,
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer',
},
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
alignments: ['left', 'center', 'right'],
defaultAlignment: 'left',
}),
],
content,
onUpdate: ({ editor }) => {
const html = editor.getHTML();
onChange(html);
setSourceContent(html);
},
});
// Sync editor when content prop changes externally
React.useEffect(() => {
if (editor && !isSourceMode && content !== editor.getHTML()) {
editor.commands.setContent(content);
setSourceContent(content);
}
}, [content, editor, isSourceMode]);
const handleSourceChange = useCallback((value: string) => {
setSourceContent(value);
onChange(value);
}, [onChange]);
const switchToVisual = useCallback(() => {
if (editor) {
editor.commands.setContent(sourceContent);
}
setIsSourceMode(false);
}, [editor, sourceContent]);
const switchToSource = useCallback(() => {
if (editor) {
setSourceContent(editor.getHTML());
}
setIsSourceMode(true);
}, [editor]);
const insertVariable = useCallback((variable: string) => {
const tag = `{{${variable}}}`;
if (isSourceMode) {
// Insert at cursor in textarea
const textarea = document.querySelector('[data-email-source]') as HTMLTextAreaElement;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newContent = sourceContent.substring(0, start) + tag + sourceContent.substring(end);
setSourceContent(newContent);
onChange(newContent);
// Restore cursor position after React re-render
requestAnimationFrame(() => {
textarea.selectionStart = textarea.selectionEnd = start + tag.length;
textarea.focus();
});
}
} else if (editor) {
editor.chain().focus().insertContent(tag).run();
}
setShowVariables(false);
}, [editor, isSourceMode, sourceContent, onChange]);
const addLink = useCallback(() => {
if (linkUrl && editor) {
editor.chain().focus().setLink({ href: linkUrl }).run();
setLinkUrl('');
setShowLinkDialog(false);
}
}, [editor, linkUrl]);
if (!editor) {
return null;
}
const MenuButton: React.FC<{
onClick: () => void;
active?: boolean;
children: React.ReactNode;
title: string;
disabled?: boolean;
}> = ({ onClick, active, children, title, disabled }) => (
<button
onClick={onClick}
disabled={disabled}
className={`p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-600 transition-colors ${
active
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300'
: 'text-neutral-700 dark:text-neutral-300'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
title={title}
type="button"
>
{children}
</button>
);
return (
<div className="border border-neutral-300 dark:border-neutral-600 rounded-lg overflow-hidden">
{/* Toolbar */}
<div className="border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800">
<div className="flex items-center justify-between p-2">
{/* Formatting buttons */}
<div className="flex items-center gap-0.5 flex-wrap">
{!isSourceMode && (
<>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
<Heading2 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
active={editor.isActive('heading', { level: 3 })}
title="Heading 3"
>
<Heading3 className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
title={`${t('email.editor.bold')} (Ctrl+B)`}
>
<Bold className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
title={`${t('email.editor.italic')} (Ctrl+I)`}
>
<Italic className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
title={t('email.editor.bulletList')}
>
<List className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
title={t('email.editor.numberedList')}
>
<ListOrdered className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive('blockquote')}
title={t('email.editor.blockquote')}
>
<Quote className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => setShowLinkDialog(true)}
active={editor.isActive('link')}
title={t('email.editor.link')}
>
<LinkIcon className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title={t('email.editor.horizontalRule')}
>
<Minus className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('left').run()}
active={editor.isActive({ textAlign: 'left' })}
title={t('email.editor.alignLeft')}
>
<AlignLeft className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('center').run()}
active={editor.isActive({ textAlign: 'center' })}
title={t('email.editor.alignCenter')}
>
<AlignCenter className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('right').run()}
active={editor.isActive({ textAlign: 'right' })}
title={t('email.editor.alignRight')}
>
<AlignRight className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
title={t('email.editor.clearFormatting')}
>
<RemoveFormatting className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title={`${t('email.editor.undo')} (Ctrl+Z)`}
>
<Undo className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title={`${t('email.editor.redo')} (Ctrl+Y)`}
>
<Redo className="w-4 h-4" />
</MenuButton>
</>
)}
</div>
{/* Right side: Variables + Source toggle */}
<div className="flex items-center gap-2">
{variables.length > 0 && (
<div className="relative">
<button
onClick={() => setShowVariables(!showVariables)}
className={`flex items-center gap-1 px-2 py-1 text-xs font-medium rounded transition-colors ${
showVariables
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300'
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
}`}
type="button"
>
<Variable className="w-3.5 h-3.5" />
{t('email.editor.insertVariable')}
</button>
{showVariables && (
<div className="absolute right-0 top-full mt-1 z-10 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-600 rounded-lg shadow-lg py-1 min-w-[200px] max-h-[240px] overflow-auto">
{variables.map(variable => (
<button
key={variable}
onClick={() => insertVariable(variable)}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
type="button"
>
<code className="text-primary-600 dark:text-primary-400">{`{{${variable}}}`}</code>
</button>
))}
</div>
)}
</div>
)}
<button
onClick={isSourceMode ? switchToVisual : switchToSource}
className={`flex items-center gap-1 px-2 py-1 text-xs font-medium rounded transition-colors ${
isSourceMode
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300'
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
}`}
type="button"
>
<Code2 className="w-3.5 h-3.5" />
{isSourceMode ? t('email.editor.visualMode') : t('email.editor.sourceMode')}
</button>
</div>
</div>
</div>
{/* Link Dialog */}
{showLinkDialog && (
<div className="p-3 bg-primary-50 dark:bg-primary-900/20 border-b border-primary-200 dark:border-primary-800 flex items-center gap-2">
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addLink()}
placeholder={t('email.editor.enterUrl')}
className="flex-1 px-3 py-1 text-sm border border-primary-300 dark:border-primary-700 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<button
onClick={addLink}
className="px-3 py-1 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700"
type="button"
>
{t('email.editor.addLink')}
</button>
<button
onClick={() => { setShowLinkDialog(false); setLinkUrl(''); }}
className="px-3 py-1 text-sm bg-neutral-200 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-md hover:bg-neutral-300 dark:hover:bg-neutral-600"
type="button"
>
{t('email.editor.cancel')}
</button>
</div>
)}
{/* Editor / Source Content Area */}
{isSourceMode ? (
<textarea
data-email-source=""
value={sourceContent}
onChange={(e) => handleSourceChange(e.target.value)}
rows={15}
className="w-full px-3 py-2 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 font-mono text-sm focus:outline-none resize-y"
spellCheck={false}
/>
) : (
<EditorContent
editor={editor}
className="min-h-[300px] p-4 prose prose-neutral dark:prose-invert max-w-none bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 focus:outline-none [&_.ProseMirror]:min-h-[300px] [&_.ProseMirror]:outline-none [&_.ProseMirror]:text-neutral-900 [&_.ProseMirror]:dark:text-neutral-100 [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-400 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0"
/>
)}
</div>
);
};
EmailTemplateEditor.displayName = 'EmailTemplateEditor';
+22 -1
View File
@@ -1959,7 +1959,28 @@
"enterPassword": "Passwort eingeben",
"required": "erforderlich",
"ignoreSslErrors": "SSL/TLS-Zertifikatfehler ignorieren",
"ignoreSslWarning": "Warnung: Das Deaktivieren der Zertifikatüberprüfung macht die Verbindung anfällig für Man-in-the-Middle-Angriffe. Aktivieren Sie dies nur, wenn Sie dem SMTP-Server vertrauen und die Sicherheitsrisiken verstehen."
"ignoreSslWarning": "Warnung: Das Deaktivieren der Zertifikatüberprüfung macht die Verbindung anfällig für Man-in-the-Middle-Angriffe. Aktivieren Sie dies nur, wenn Sie dem SMTP-Server vertrauen und die Sicherheitsrisiken verstehen.",
"editor": {
"bold": "Fett",
"italic": "Kursiv",
"bulletList": "Aufzählung",
"numberedList": "Nummerierte Liste",
"blockquote": "Zitat",
"link": "Link hinzufügen",
"horizontalRule": "Trennlinie",
"alignLeft": "Linksbündig",
"alignCenter": "Zentriert",
"alignRight": "Rechtsbündig",
"clearFormatting": "Formatierung entfernen",
"undo": "Rückgängig",
"redo": "Wiederholen",
"insertVariable": "Variable einfügen",
"sourceMode": "Quellcode",
"visualMode": "Visuell",
"enterUrl": "URL eingeben...",
"addLink": "Hinzufügen",
"cancel": "Abbrechen"
}
},
"cms": {
"title": "CMS-Seiten",
+22 -1
View File
@@ -1656,7 +1656,28 @@
"fromName": "From Name",
"required": "required",
"ignoreSslErrors": "Ignore SSL/TLS certificate errors",
"ignoreSslWarning": "Warning: Disabling certificate verification makes the connection vulnerable to man-in-the-middle attacks. Only enable this if you trust the SMTP server and understand the security implications."
"ignoreSslWarning": "Warning: Disabling certificate verification makes the connection vulnerable to man-in-the-middle attacks. Only enable this if you trust the SMTP server and understand the security implications.",
"editor": {
"bold": "Bold",
"italic": "Italic",
"bulletList": "Bullet List",
"numberedList": "Numbered List",
"blockquote": "Blockquote",
"link": "Add Link",
"horizontalRule": "Horizontal Rule",
"alignLeft": "Align Left",
"alignCenter": "Align Center",
"alignRight": "Align Right",
"clearFormatting": "Clear Formatting",
"undo": "Undo",
"redo": "Redo",
"insertVariable": "Insert Variable",
"sourceMode": "Source",
"visualMode": "Visual",
"enterUrl": "Enter URL...",
"addLink": "Add",
"cancel": "Cancel"
}
},
"cms": {
"title": "CMS Pages",
+22 -1
View File
@@ -1632,7 +1632,28 @@
"enterPassword": "Введите пароль",
"required": "обязательно",
"ignoreSslErrors": "Игнорировать ошибки SSL/TLS-сертификата",
"ignoreSslWarning": "Предупреждение: Отключение проверки сертификата делает соединение уязвимым для атак «человек посередине». Включайте только если доверяете SMTP-серверу и понимаете риски."
"ignoreSslWarning": "Предупреждение: Отключение проверки сертификата делает соединение уязвимым для атак «человек посередине». Включайте только если доверяете SMTP-серверу и понимаете риски.",
"editor": {
"bold": "Жирный",
"italic": "Курсив",
"bulletList": "Маркированный список",
"numberedList": "Нумерованный список",
"blockquote": "Цитата",
"link": "Добавить ссылку",
"horizontalRule": "Горизонтальная линия",
"alignLeft": "По левому краю",
"alignCenter": "По центру",
"alignRight": "По правому краю",
"clearFormatting": "Очистить форматирование",
"undo": "Отменить",
"redo": "Повторить",
"insertVariable": "Вставить переменную",
"sourceMode": "Код",
"visualMode": "Визуально",
"enterUrl": "Введите URL...",
"addLink": "Добавить",
"cancel": "Отмена"
}
},
"cms": {
"title": "Страницы CMS",
+6 -27
View File
@@ -16,6 +16,7 @@ import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { emailService, type EmailConfig, type EmailTemplate } from '../../services/email.service';
import { useTranslation } from 'react-i18next';
@@ -253,25 +254,6 @@ export const EmailConfigPage: React.FC = () => {
}
};
const renderVariableHelp = () => {
const variables = editedTemplate.variables || [];
return (
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h4 className="text-sm font-semibold text-blue-900 dark:text-blue-200 mb-2">{t('email.templateVariables')}</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
{variables.map(variable => (
<code key={variable} className="text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 px-2 py-1 rounded">
{`{{${variable}}}`}
</code>
))}
</div>
<p className="text-xs text-blue-700 dark:text-blue-300 mt-2">
{t('email.variableHelp')}
</p>
</div>
);
};
if (configLoading || templatesLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
@@ -632,22 +614,19 @@ export const EmailConfigPage: React.FC = () => {
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('email.emailBody')} ({editingLang === 'en' ? 'English' : 'German'})
</label>
<textarea
value={
<EmailTemplateEditor
content={
editingLang === 'en'
? (editedTemplate.body_html_en || editedTemplate.body_html || '')
: (editedTemplate.body_html_de || '')
}
onChange={(e) => setEditedTemplate(prev => ({
onChange={(value) => setEditedTemplate(prev => ({
...prev,
[editingLang === 'en' ? 'body_html_en' : 'body_html_de']: e.target.value
[editingLang === 'en' ? 'body_html_en' : 'body_html_de']: value
}))}
rows={15}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono text-sm"
variables={editedTemplate.variables || []}
/>
</div>
{renderVariableHelp()}
</div>
</Card>
</div>