diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8a4ae87..023d681 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,18 +1,24 @@ { "name": "picpeak-frontend", - "version": "1.0.58", + "version": "1.0.60", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-frontend", - "version": "1.0.58", + "version": "1.0.60", "dependencies": { "@tanstack/react-query": "^5.0.0", + "@tiptap/extension-character-count": "^2.26.1", + "@tiptap/extension-code-block-lowlight": "^2.26.1", + "@tiptap/extension-hard-break": "^2.26.1", "@tiptap/extension-link": "^2.25.0", + "@tiptap/extension-placeholder": "^2.26.1", + "@tiptap/extension-text-align": "^2.26.1", "@tiptap/react": "^2.25.0", "@tiptap/starter-kit": "^2.25.0", "@types/dompurify": "^3.0.5", + "@types/lodash": "^4.17.20", "@types/react-google-recaptcha": "^2.1.9", "axios": "^1.3.2", "clsx": "^2.0.0", @@ -22,6 +28,8 @@ "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "js-cookie": "^3.0.5", + "lodash": "^4.17.21", + "lowlight": "^2.9.0", "lucide-react": "^0.292.0", "react": "^18.3.1", "react-countdown": "^2.3.5", @@ -1541,6 +1549,20 @@ "@tiptap/core": "^2.7.0" } }, + "node_modules/@tiptap/extension-character-count": { + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-2.26.1.tgz", + "integrity": "sha512-F7LP1a9GF28thbApowWT2I41baqX74HMUTrV9LGrNXaOkW2gxZz+CDOzfHsbHyfuwfIxIjv07Qf/HKA6Cc1qbA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, "node_modules/@tiptap/extension-code": { "version": "2.25.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.25.0.tgz", @@ -1568,6 +1590,23 @@ "@tiptap/pm": "^2.7.0" } }, + "node_modules/@tiptap/extension-code-block-lowlight": { + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.26.1.tgz", + "integrity": "sha512-yptuTPYAzVMKHUTwNKYveuu0rYHYyFknPz3O2++PWeeBGxkNB+T6LhwZ/JhXceHcZxzlGyka9r2mXR7pslhugw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/extension-code-block": "^2.7.0", + "@tiptap/pm": "^2.7.0", + "highlight.js": "^11", + "lowlight": "^2 || ^3" + } + }, "node_modules/@tiptap/extension-document": { "version": "2.25.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.25.0.tgz", @@ -1627,9 +1666,9 @@ } }, "node_modules/@tiptap/extension-hard-break": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.25.0.tgz", - "integrity": "sha512-h8be5Zdtsl5GQHxRXvYlGfIJsLvdbexflSTr12gr4kvcQqTdtrsqyu2eksfAK+p2szbiwP2G4VZlH0LNS47UXQ==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.26.1.tgz", + "integrity": "sha512-d6uStdNKi8kjPlHAyO59M6KGWATNwhLCD7dng0NXfwGndc22fthzIk/6j9F6ltQx30huy5qQram6j3JXwNACoA==", "license": "MIT", "funding": { "type": "github", @@ -1749,6 +1788,20 @@ "@tiptap/core": "^2.7.0" } }, + "node_modules/@tiptap/extension-placeholder": { + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.26.1.tgz", + "integrity": "sha512-MBlqbkd+63btY7Qu+SqrXvWjPwooGZDsLTtl7jp52BczBl61cq9yygglt9XpM11TFMBdySgdLHBrLtQ0B7fBlw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, "node_modules/@tiptap/extension-strike": { "version": "2.25.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.25.0.tgz", @@ -1775,6 +1828,19 @@ "@tiptap/core": "^2.7.0" } }, + "node_modules/@tiptap/extension-text-align": { + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.26.1.tgz", + "integrity": "sha512-x6mpNGELy2QtSPBoQqNgiXO9PjZoB+O2EAfXA9YRiBDSIRNOrw+7vOVpi+IgzswFmhMNgIYUVfQRud4FHUCNew==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, "node_modules/@tiptap/extension-text-style": { "version": "2.25.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.25.0.tgz", @@ -1935,6 +2001,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hast": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", + "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, "node_modules/@types/js-cookie": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", @@ -1955,6 +2030,12 @@ "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "license": "MIT" + }, "node_modules/@types/markdown-it": { "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", @@ -2012,6 +2093,12 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -3233,6 +3320,19 @@ "reusify": "^1.0.4" } }, + "node_modules/fault": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", + "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3350,6 +3450,14 @@ "node": ">= 6" } }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -3576,6 +3684,16 @@ "node": ">= 0.4" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -3935,6 +4053,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3954,6 +4078,30 @@ "loose-envify": "cli.js" } }, + "node_modules/lowlight": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.9.0.tgz", + "integrity": "sha512-OpcaUTCLmHuVuBcyNckKfH5B0oA4JUavb/M/8n9iAvanJYNQkrVm4pvyX0SUaqkBG4dnWHKt7p50B3ngAG2Rfw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "fault": "^2.0.0", + "highlight.js": "~11.8.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lowlight/node_modules/highlight.js": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", + "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index ee051af..dda499d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "picpeak-frontend", "private": true, - "version": "1.0.58", + "version": "1.0.60", "type": "module", "scripts": { "dev": "vite", @@ -12,10 +12,16 @@ }, "dependencies": { "@tanstack/react-query": "^5.0.0", + "@tiptap/extension-character-count": "^2.26.1", + "@tiptap/extension-code-block-lowlight": "^2.26.1", + "@tiptap/extension-hard-break": "^2.26.1", "@tiptap/extension-link": "^2.25.0", + "@tiptap/extension-placeholder": "^2.26.1", + "@tiptap/extension-text-align": "^2.26.1", "@tiptap/react": "^2.25.0", "@tiptap/starter-kit": "^2.25.0", "@types/dompurify": "^3.0.5", + "@types/lodash": "^4.17.20", "@types/react-google-recaptcha": "^2.1.9", "axios": "^1.3.2", "clsx": "^2.0.0", @@ -25,6 +31,8 @@ "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "js-cookie": "^3.0.5", + "lodash": "^4.17.21", + "lowlight": "^2.9.0", "lucide-react": "^0.292.0", "react": "^18.3.1", "react-countdown": "^2.3.5", diff --git a/frontend/src/components/admin/CMSEditor.tsx b/frontend/src/components/admin/CMSEditor.tsx index bec8bab..78002bb 100644 --- a/frontend/src/components/admin/CMSEditor.tsx +++ b/frontend/src/components/admin/CMSEditor.tsx @@ -1,7 +1,13 @@ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Link from '@tiptap/extension-link'; +import HardBreak from '@tiptap/extension-hard-break'; +import Placeholder from '@tiptap/extension-placeholder'; +import CharacterCount from '@tiptap/extension-character-count'; +import TextAlign from '@tiptap/extension-text-align'; +import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; +import { lowlight } from 'lowlight'; import { Bold, Italic, @@ -10,33 +16,103 @@ import { Link as LinkIcon, Heading1, Heading2, + Heading3, + Heading4, + Heading5, + Heading6, + Quote, + Code, + Code2, + Minus, Undo, - Redo + Redo, + RemoveFormatting, + AlignLeft, + AlignCenter, + AlignRight, + AlignJustify, + Eye, + Edit3, + Columns, + Maximize2, + HelpCircle, + Save } from 'lucide-react'; import { Button } from '../common'; +import DOMPurify from 'dompurify'; +import '../../styles/prose-overrides.css'; interface CMSEditorProps { content: string; onChange: (content: string) => void; + onSave?: () => void; + isSaving?: boolean; } -export const CMSEditor: React.FC = ({ content, onChange }) => { +type ViewMode = 'edit' | 'preview' | 'split'; + +export const CMSEditor: React.FC = ({ content, onChange, onSave, isSaving }) => { const [linkUrl, setLinkUrl] = useState(''); const [showLinkDialog, setShowLinkDialog] = useState(false); + const [viewMode, setViewMode] = useState('edit'); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showHelp, setShowHelp] = useState(false); + const [wordCount, setWordCount] = useState(0); + const [charCount, setCharCount] = useState(0); const editor = useEditor({ extensions: [ - StarterKit, + StarterKit.configure({ + hardBreak: false, // We'll use the separate HardBreak extension + codeBlock: false, // We'll use CodeBlockLowlight instead + }), + HardBreak.configure({ + keepMarks: true, + HTMLAttributes: { + class: 'hard-break', + }, + }), Link.configure({ openOnClick: false, + HTMLAttributes: { + target: '_blank', + rel: 'noopener noreferrer', + }, + }), + TextAlign.configure({ + types: ['heading', 'paragraph'], + alignments: ['left', 'center', 'right', 'justify'], + defaultAlignment: 'left', + }), + CodeBlockLowlight.configure({ + lowlight, + HTMLAttributes: { + class: 'hljs', + }, + }), + Placeholder.configure({ + placeholder: 'Start typing your content here...', + }), + CharacterCount.configure({ + limit: null, }), ], content, onUpdate: ({ editor }) => { onChange(editor.getHTML()); + updateCounts(editor); + }, + onCreate: ({ editor }) => { + updateCounts(editor); }, }); + const updateCounts = useCallback((editor: any) => { + const text = editor.state.doc.textContent; + setCharCount(editor.storage.characterCount.characters()); + setWordCount(text.trim().split(/\s+/).filter(word => word.length > 0).length); + }, []); + // Update editor content when prop changes React.useEffect(() => { if (editor && content !== editor.getHTML()) { @@ -61,12 +137,14 @@ export const CMSEditor: React.FC = ({ content, onChange }) => { active?: boolean; children: React.ReactNode; title: string; - }> = ({ onClick, active, children, title }) => ( + disabled?: boolean; + }> = ({ onClick, active, children, title, disabled }) => ( ); + const toggleFullscreen = () => { + setIsFullscreen(!isFullscreen); + }; + + const getPreviewContent = () => { + return DOMPurify.sanitize(editor?.getHTML() || '', { + ALLOWED_TAGS: [ + 'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'ul', 'ol', 'li', 'blockquote', 'a', 'em', 'strong', + 'code', 'pre', 'hr', 'div', 'span' + ], + ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'], + ALLOW_DATA_ATTR: false, + KEEP_CONTENT: true, + ADD_TAGS: ['br'], // Explicitly allow br tags + ADD_ATTR: ['style'], // Allow style for text alignment + }); + }; + return ( -
- {/* Toolbar */} -
- editor.chain().focus().toggleHeading({ level: 1 }).run()} - active={editor.isActive('heading', { level: 1 })} - title="Heading 1" - > - - - - editor.chain().focus().toggleHeading({ level: 2 }).run()} - active={editor.isActive('heading', { level: 2 })} - title="Heading 2" - > - - +
+
+ {/* Top Toolbar */} +
+ {/* View Mode Controls */} +
+
+ + + +
+ +
+ {onSave && ( + + )} + + setShowHelp(true)} + title="Help & Keyboard Shortcuts" + > + + + + + + +
+
-
- - editor.chain().focus().toggleBold().run()} - active={editor.isActive('bold')} - title="Bold" - > - - - - editor.chain().focus().toggleItalic().run()} - active={editor.isActive('italic')} - title="Italic" - > - - + {/* Formatting Toolbar */} + {viewMode !== 'preview' && ( +
+ editor.chain().focus().toggleHeading({ level: 1 }).run()} + active={editor.isActive('heading', { level: 1 })} + title="Heading 1 (Ctrl+Alt+1)" + > + + + + editor.chain().focus().toggleHeading({ level: 2 }).run()} + active={editor.isActive('heading', { level: 2 })} + title="Heading 2 (Ctrl+Alt+2)" + > + + + + editor.chain().focus().toggleHeading({ level: 3 }).run()} + active={editor.isActive('heading', { level: 3 })} + title="Heading 3 (Ctrl+Alt+3)" + > + + + + editor.chain().focus().toggleHeading({ level: 4 }).run()} + active={editor.isActive('heading', { level: 4 })} + title="Heading 4 (Ctrl+Alt+4)" + > + + + + editor.chain().focus().toggleHeading({ level: 5 }).run()} + active={editor.isActive('heading', { level: 5 })} + title="Heading 5 (Ctrl+Alt+5)" + > + + + + editor.chain().focus().toggleHeading({ level: 6 }).run()} + active={editor.isActive('heading', { level: 6 })} + title="Heading 6 (Ctrl+Alt+6)" + > + + -
- - editor.chain().focus().toggleBulletList().run()} - active={editor.isActive('bulletList')} - title="Bullet List" - > - - - - editor.chain().focus().toggleOrderedList().run()} - active={editor.isActive('orderedList')} - title="Ordered List" - > - - +
+ + editor.chain().focus().toggleBold().run()} + active={editor.isActive('bold')} + title="Bold (Ctrl+B)" + > + + + + editor.chain().focus().toggleItalic().run()} + active={editor.isActive('italic')} + title="Italic (Ctrl+I)" + > + + + + editor.chain().focus().toggleCode().run()} + active={editor.isActive('code')} + title="Inline Code (Ctrl+E)" + > + + + + editor.chain().focus().toggleCodeBlock().run()} + active={editor.isActive('codeBlock')} + title="Code Block (Ctrl+Alt+C)" + > + + -
- - setShowLinkDialog(true)} - active={editor.isActive('link')} - title="Add Link" - > - - +
+ + editor.chain().focus().toggleBulletList().run()} + active={editor.isActive('bulletList')} + title="Bullet List (Ctrl+Shift+8)" + > + + + + editor.chain().focus().toggleOrderedList().run()} + active={editor.isActive('orderedList')} + title="Numbered List (Ctrl+Shift+9)" + > + + + + editor.chain().focus().toggleBlockquote().run()} + active={editor.isActive('blockquote')} + title="Blockquote (Ctrl+Shift+B)" + > + + -
- - editor.chain().focus().undo().run()} - title="Undo" - > - - - - editor.chain().focus().redo().run()} - title="Redo" - > - - +
+ + setShowLinkDialog(true)} + active={editor.isActive('link')} + title="Add Link (Ctrl+K)" + > + + + + editor.chain().focus().setHorizontalRule().run()} + title="Horizontal Rule" + > + + + +
+ + editor.chain().focus().setTextAlign('left').run()} + active={editor.isActive({ textAlign: 'left' })} + title="Align Left" + > + + + + editor.chain().focus().setTextAlign('center').run()} + active={editor.isActive({ textAlign: 'center' })} + title="Align Center" + > + + + + editor.chain().focus().setTextAlign('right').run()} + active={editor.isActive({ textAlign: 'right' })} + title="Align Right" + > + + + + editor.chain().focus().setTextAlign('justify').run()} + active={editor.isActive({ textAlign: 'justify' })} + title="Justify" + > + + + +
+ + editor.chain().focus().clearNodes().unsetAllMarks().run()} + title="Clear Formatting" + > + + + +
+ + editor.chain().focus().undo().run()} + disabled={!editor.can().undo()} + title="Undo (Ctrl+Z)" + > + + + + editor.chain().focus().redo().run()} + disabled={!editor.can().redo()} + title="Redo (Ctrl+Y)" + > + + +
+ )} +
+ + {/* Link Dialog */} + {showLinkDialog && ( +
+ setLinkUrl(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && addLink()} + placeholder="Enter URL..." + className="flex-1 px-3 py-1 border border-primary-300 rounded-md focus:ring-2 focus:ring-primary-500" + autoFocus + /> + + +
+ )} + + {/* Editor Content Area */} +
+ {/* Editor */} + {viewMode !== 'preview' && ( +
+ +
+ )} + + {/* Preview */} + {viewMode !== 'edit' && ( +
+
+
+ )} +
+ + {/* Status Bar */} +
+
+ {wordCount} words + {charCount} characters +
+
+ Press Shift+Enter for line break, Enter for new paragraph +
+
- {/* Link Dialog */} - {showLinkDialog && ( -
- setLinkUrl(e.target.value)} - onKeyPress={(e) => e.key === 'Enter' && addLink()} - placeholder="Enter URL..." - className="flex-1 px-3 py-1 border border-primary-300 rounded-md focus:ring-2 focus:ring-primary-500" - autoFocus - /> - - + {/* Help Modal */} + {showHelp && ( +
+
+
+

Editor Help & Keyboard Shortcuts

+ +
+
+

Text Formatting

+
+
Ctrl+B - Bold
+
Ctrl+I - Italic
+
Ctrl+E - Inline code
+
Ctrl+K - Add link
+
+
+ +
+

Headings

+
+
Ctrl+Alt+1 - Heading 1
+
Ctrl+Alt+2 - Heading 2
+
Ctrl+Alt+3 - Heading 3
+
Ctrl+Alt+4 - Heading 4
+
Ctrl+Alt+5 - Heading 5
+
Ctrl+Alt+6 - Heading 6
+
+
+ +
+

Lists & Blocks

+
+
Ctrl+Shift+8 - Bullet list
+
Ctrl+Shift+9 - Numbered list
+
Ctrl+Shift+B - Blockquote
+
Ctrl+Alt+C - Code block
+
+
+ +
+

Text Alignment

+
+
Click alignment buttons in toolbar
+
Works on paragraphs and headings
+
+
+ +
+

Line Breaks

+
+
Enter - New paragraph
+
Shift+Enter - Line break (preserves formatting)
+
+
+ +
+

Navigation

+
+
Ctrl+Z - Undo
+
Ctrl+Y - Redo
+
+
+
+ +
+ +
+
+
)} - - {/* Editor */} -
); }; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index fbb4c06..9d34e18 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' +import './styles/prose-overrides.css' import './i18n/config' import App from './App.tsx' diff --git a/frontend/src/pages/admin/CMSPageEnhanced.tsx b/frontend/src/pages/admin/CMSPageEnhanced.tsx new file mode 100644 index 0000000..320cdd6 --- /dev/null +++ b/frontend/src/pages/admin/CMSPageEnhanced.tsx @@ -0,0 +1,290 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { Save, FileText, Globe, Clock } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { debounce } from 'lodash'; + +import { Button, Card, Input, Loading } from '../../components/common'; +import { CMSEditor } from '../../components/admin/CMSEditor'; +import { cmsService } from '../../services/cms.service'; +import type { CMSPage as CMSPageType } from '../../services/cms.service'; + +export const CMSPageEnhanced: React.FC = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [selectedPage, setSelectedPage] = useState('impressum'); + const [editingLang, setEditingLang] = useState<'en' | 'de'>('en'); + const [editForm, setEditForm] = useState>({}); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [lastSaved, setLastSaved] = useState(null); + const [isAutoSaving, setIsAutoSaving] = useState(false); + + // Fetch CMS pages + const { data: pages, isLoading } = useQuery({ + queryKey: ['cms-pages'], + queryFn: cmsService.getPages, + }); + + // Update page mutation + const updateMutation = useMutation({ + mutationFn: ({ slug, data }: { slug: string; data: Partial }) => + cmsService.updatePage(slug, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['cms-pages'] }); + setHasUnsavedChanges(false); + setLastSaved(new Date()); + setIsAutoSaving(false); + if (!isAutoSaving) { + toast.success(t('cms.pageUpdated')); + } + }, + onError: () => { + setIsAutoSaving(false); + toast.error(t('toast.saveError')); + }, + }); + + // Auto-save functionality + const autoSave = useCallback( + debounce(() => { + if (hasUnsavedChanges && !updateMutation.isPending) { + setIsAutoSaving(true); + updateMutation.mutate({ + slug: selectedPage, + data: editForm, + }); + } + }, 3000), + [hasUnsavedChanges, editForm, selectedPage] + ); + + // Trigger auto-save when content changes + useEffect(() => { + if (hasUnsavedChanges) { + autoSave(); + } + return () => { + autoSave.cancel(); + }; + }, [hasUnsavedChanges, autoSave]); + + // Load page data when selection changes + React.useEffect(() => { + if (pages) { + const page = pages.find(p => p.slug === selectedPage); + if (page) { + setEditForm(page); + setHasUnsavedChanges(false); + } + } + }, [pages, selectedPage]); + + const handleSave = () => { + autoSave.cancel(); // Cancel any pending auto-save + updateMutation.mutate({ + slug: selectedPage, + data: editForm, + }); + }; + + const handleContentChange = (content: string) => { + const field = editingLang === 'de' ? 'content_de' : 'content_en'; + setEditForm(prev => ({ ...prev, [field]: content })); + setHasUnsavedChanges(true); + }; + + const handleTitleChange = (title: string) => { + const field = editingLang === 'de' ? 'title_de' : 'title_en'; + setEditForm(prev => ({ ...prev, [field]: title })); + setHasUnsavedChanges(true); + }; + + // Warn before leaving with unsaved changes + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (hasUnsavedChanges) { + e.preventDefault(); + e.returnValue = ''; + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [hasUnsavedChanges]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + const currentPage = pages?.find(p => p.slug === selectedPage); + + return ( +
+
+

{t('cms.title')}

+

{t('cms.subtitle')}

+
+ +
+ {/* Page Selection */} +
+ +

{t('cms.pages')}

+
+ {pages?.map((page) => ( + + ))} +
+
+ + +

{t('cms.previewLinks')}

+ +
+ + {/* Auto-save status */} + {(hasUnsavedChanges || lastSaved) && ( + +
+ {isAutoSaving && ( +
+
+ Auto-saving... +
+ )} + {!isAutoSaving && hasUnsavedChanges && ( +
+
+ Unsaved changes +
+ )} + {!hasUnsavedChanges && lastSaved && ( +
+ + Saved {new Date(lastSaved).toLocaleTimeString()} +
+ )} +
+ + )} +
+ + {/* Editor */} +
+ +
+

+ {t('cms.editPage', { page: t(`legal.${selectedPage}`) })} +

+ + {/* Language Tabs */} +
+ + +
+
+ +
+ {/* Title */} +
+ + handleTitleChange(e.target.value)} + placeholder={t('cms.pageTitlePlaceholder')} + /> +
+ + {/* Content */} +
+ + +
+
+ + {currentPage?.updated_at && ( +

+ {t('cms.lastUpdated')} {new Date(currentPage.updated_at).toLocaleString()} +

+ )} +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/pages/public/LegalPage.tsx b/frontend/src/pages/public/LegalPage.tsx index e736bb2..0b4ab62 100644 --- a/frontend/src/pages/public/LegalPage.tsx +++ b/frontend/src/pages/public/LegalPage.tsx @@ -7,6 +7,7 @@ import DOMPurify from 'dompurify'; import { Loading, Card } from '../../components/common'; import { cmsService } from '../../services/cms.service'; import { api } from '../../config/api'; +import '../../styles/prose-overrides.css'; export const LegalPage: React.FC = () => { const { slug } = useParams<{ slug: string }>(); @@ -104,7 +105,20 @@ export const LegalPage: React.FC = () => {
diff --git a/frontend/src/styles/prose-overrides.css b/frontend/src/styles/prose-overrides.css new file mode 100644 index 0000000..9dcc9b2 --- /dev/null +++ b/frontend/src/styles/prose-overrides.css @@ -0,0 +1,98 @@ +/* Prose overrides for CMS content */ + +/* Preserve line breaks in prose content */ +.prose br { + display: block !important; + content: "" !important; + margin: 0.5em 0 !important; +} + +/* Hard breaks should create visible line breaks */ +.prose .hard-break { + display: block !important; + height: 0.5em !important; +} + +/* Ensure paragraphs have proper spacing */ +.prose p { + margin-top: 1em; + margin-bottom: 1em; +} + +.prose p:first-child { + margin-top: 0; +} + +.prose p:last-child { + margin-bottom: 0; +} + +/* Code block styling */ +.prose pre { + background-color: #f5f5f5; + border-radius: 0.375rem; + padding: 1rem; + overflow-x: auto; + margin: 1.5em 0; +} + +.prose pre code { + background-color: transparent; + padding: 0; + font-size: 0.875em; + color: inherit; +} + +/* Inline code styling */ +.prose code { + background-color: #f5f5f5; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.875em; + font-weight: 400; +} + +/* Text alignment classes */ +.prose .text-left { + text-align: left !important; +} + +.prose .text-center { + text-align: center !important; +} + +.prose .text-right { + text-align: right !important; +} + +.prose .text-justify { + text-align: justify !important; +} + +/* Syntax highlighting for code blocks */ +.prose .hljs { + background: transparent !important; + padding: 0 !important; +} + +/* Basic syntax highlighting colors */ +.hljs-keyword { color: #a626a4; } +.hljs-string { color: #50a14f; } +.hljs-comment { color: #a0a1a7; font-style: italic; } +.hljs-number { color: #e45649; } +.hljs-function { color: #4078f2; } +.hljs-tag { color: #e45649; } +.hljs-attribute { color: #986801; } +.hljs-selector-class { color: #986801; } +.hljs-selector-id { color: #986801; } + +/* Preserve whitespace in content */ +.prose-preserve-whitespace { + white-space: pre-wrap; +} + +/* Ensure empty paragraphs with only line breaks are visible */ +.prose p:empty::before { + content: "\200B"; /* Zero-width space */ + display: inline; +} \ No newline at end of file