From 04a7ea80f95d6aeb474b145292e75f45fb85c66d Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 15 Mar 2026 22:05:27 +0100 Subject: [PATCH] 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. --- docker-compose.yml | 10 + .../components/admin/EmailTemplateEditor.tsx | 398 ++++++++++++++++++ frontend/src/i18n/locales/de.json | 23 +- frontend/src/i18n/locales/en.json | 23 +- frontend/src/i18n/locales/ru.json | 23 +- frontend/src/pages/admin/EmailConfigPage.tsx | 33 +- 6 files changed, 480 insertions(+), 30 deletions(-) create mode 100644 frontend/src/components/admin/EmailTemplateEditor.tsx diff --git a/docker-compose.yml b/docker-compose.yml index a4cc168b..41324b3c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/components/admin/EmailTemplateEditor.tsx b/frontend/src/components/admin/EmailTemplateEditor.tsx new file mode 100644 index 00000000..56c3686c --- /dev/null +++ b/frontend/src/components/admin/EmailTemplateEditor.tsx @@ -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 = ({ + 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 }) => ( + + ); + + return ( +
+ {/* Toolbar */} +
+
+ {/* Formatting buttons */} +
+ {!isSourceMode && ( + <> + editor.chain().focus().toggleHeading({ level: 2 }).run()} + active={editor.isActive('heading', { level: 2 })} + title="Heading 2" + > + + + + editor.chain().focus().toggleHeading({ level: 3 }).run()} + active={editor.isActive('heading', { level: 3 })} + title="Heading 3" + > + + + +
+ + editor.chain().focus().toggleBold().run()} + active={editor.isActive('bold')} + title={`${t('email.editor.bold')} (Ctrl+B)`} + > + + + + editor.chain().focus().toggleItalic().run()} + active={editor.isActive('italic')} + title={`${t('email.editor.italic')} (Ctrl+I)`} + > + + + +
+ + editor.chain().focus().toggleBulletList().run()} + active={editor.isActive('bulletList')} + title={t('email.editor.bulletList')} + > + + + + editor.chain().focus().toggleOrderedList().run()} + active={editor.isActive('orderedList')} + title={t('email.editor.numberedList')} + > + + + + editor.chain().focus().toggleBlockquote().run()} + active={editor.isActive('blockquote')} + title={t('email.editor.blockquote')} + > + + + +
+ + setShowLinkDialog(true)} + active={editor.isActive('link')} + title={t('email.editor.link')} + > + + + + editor.chain().focus().setHorizontalRule().run()} + title={t('email.editor.horizontalRule')} + > + + + +
+ + editor.chain().focus().setTextAlign('left').run()} + active={editor.isActive({ textAlign: 'left' })} + title={t('email.editor.alignLeft')} + > + + + + editor.chain().focus().setTextAlign('center').run()} + active={editor.isActive({ textAlign: 'center' })} + title={t('email.editor.alignCenter')} + > + + + + editor.chain().focus().setTextAlign('right').run()} + active={editor.isActive({ textAlign: 'right' })} + title={t('email.editor.alignRight')} + > + + + +
+ + editor.chain().focus().clearNodes().unsetAllMarks().run()} + title={t('email.editor.clearFormatting')} + > + + + + editor.chain().focus().undo().run()} + disabled={!editor.can().undo()} + title={`${t('email.editor.undo')} (Ctrl+Z)`} + > + + + + editor.chain().focus().redo().run()} + disabled={!editor.can().redo()} + title={`${t('email.editor.redo')} (Ctrl+Y)`} + > + + + + )} +
+ + {/* Right side: Variables + Source toggle */} +
+ {variables.length > 0 && ( +
+ + + {showVariables && ( +
+ {variables.map(variable => ( + + ))} +
+ )} +
+ )} + + +
+
+
+ + {/* Link Dialog */} + {showLinkDialog && ( +
+ 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 + /> + + +
+ )} + + {/* Editor / Source Content Area */} + {isSourceMode ? ( +