From d2663bff812efe81ff500f75cf8e2e8e285ad33d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:48:06 +0000 Subject: [PATCH 001/182] chore(beta): release 3.17.2-beta.0 --- .release-please-manifest-beta.json | 2 +- CHANGELOG.md | 10 ++++++++++ backend/package.json | 2 +- frontend/package.json | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json index 07c082e7..b6ccd49b 100644 --- a/.release-please-manifest-beta.json +++ b/.release-please-manifest-beta.json @@ -1,3 +1,3 @@ { - ".": "3.17.1-beta.0" + ".": "3.17.2-beta.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index a95c889b..947dc07c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to PicPeak will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.17.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.1-beta.0...v3.17.2-beta.0) (2026-03-11) + + +### Bug Fixes + +* update security policy with private reporting channels ([308e086](https://github.com/the-luap/picpeak/commit/308e08626383bab213ce3eb5563608dff6168ef4)) +* update security policy with proper contact email and private reporting ([67b0f32](https://github.com/the-luap/picpeak/commit/67b0f32456d0216e4c685a104c680fa5a5fd578f)), closes [#223](https://github.com/the-luap/picpeak/issues/223) +* video upload media type, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([fc75bcd](https://github.com/the-luap/picpeak/commit/fc75bcdfc38673d6e4dd1cd943cfb4638d3a306c)) +* video upload, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([a0bb080](https://github.com/the-luap/picpeak/commit/a0bb0805868e742f323b64312c3c5ef8ec408f68)) + ## [3.17.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.0-beta.0...v3.17.1-beta.0) (2026-03-08) diff --git a/backend/package.json b/backend/package.json index bd3126f6..4c514640 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "picpeak-backend", - "version": "3.17.1-beta.0", + "version": "3.17.2-beta.0", "description": "Backend for PicPeak event photo sharing platform", "main": "server.js", "scripts": { diff --git a/frontend/package.json b/frontend/package.json index 74bf24ef..8c11a30c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "picpeak-frontend", "private": true, - "version": "3.17.1-beta.0", + "version": "3.17.2-beta.0", "type": "module", "scripts": { "dev": "vite", From 04a7ea80f95d6aeb474b145292e75f45fb85c66d Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 15 Mar 2026 22:05:27 +0100 Subject: [PATCH 002/182] 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 ? ( +