feat: enhance CMS editor with rich text features and fix line break preservation
Mirror to GitHub / mirror (push) Successful in 22s
Test and Lint / backend-test (push) Successful in 1m16s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m19s
Version and Release / version-bump (push) Failing after 38s
Version and Release / trigger-drone (push) Has been skipped
Mirror to GitHub / mirror (push) Successful in 22s
Test and Lint / backend-test (push) Successful in 1m16s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m19s
Version and Release / version-bump (push) Failing after 38s
Version and Release / trigger-drone (push) Has been skipped
- Fix line break preservation with proper HardBreak extension configuration - Add text alignment support (left, center, right, justify) for paragraphs and headings - Add H4-H6 heading support with keyboard shortcuts - Add code block support with syntax highlighting for multiple languages - Enhance preview functionality with split-pane view (Edit/Preview/Split modes) - Add custom prose CSS overrides for proper content rendering - Update DOMPurify configuration to preserve br tags and text alignment - Add visual indicators and help modal with all keyboard shortcuts - Improve toolbar organization with grouped buttons and separators - Add auto-save status indicator and word/character count Fixes issues with: - Line breaks not showing in gallery view - Limited heading options (only H1-H3) - No text formatting capabilities - Missing preview functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Generated
+153
-5
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<CMSEditorProps> = ({ content, onChange }) => {
|
||||
type ViewMode = 'edit' | 'preview' | 'split';
|
||||
|
||||
export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave, isSaving }) => {
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [showLinkDialog, setShowLinkDialog] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('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<CMSEditorProps> = ({ content, onChange }) => {
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
}> = ({ onClick, active, children, title }) => (
|
||||
disabled?: boolean;
|
||||
}> = ({ onClick, active, children, title, disabled }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`p-2 rounded hover:bg-neutral-100 ${
|
||||
disabled={disabled}
|
||||
className={`p-2 rounded hover:bg-neutral-100 transition-colors ${
|
||||
active ? 'bg-primary-100 text-primary-700' : 'text-neutral-700'
|
||||
}`}
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title={title}
|
||||
type="button"
|
||||
>
|
||||
@@ -74,116 +152,418 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange }) => {
|
||||
</button>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="border border-neutral-300 rounded-lg overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 p-2 border-b border-neutral-200 bg-neutral-50 flex-wrap">
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
active={editor.isActive('heading', { level: 1 })}
|
||||
title="Heading 1"
|
||||
>
|
||||
<Heading1 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<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>
|
||||
<div className={`relative ${isFullscreen ? 'fixed inset-0 z-50 bg-white' : ''}`}>
|
||||
<div className="border border-neutral-300 rounded-lg overflow-hidden h-full flex flex-col">
|
||||
{/* Top Toolbar */}
|
||||
<div className="border-b border-neutral-200 bg-neutral-50">
|
||||
{/* View Mode Controls */}
|
||||
<div className="flex items-center justify-between p-2 border-b border-neutral-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode('edit')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
||||
viewMode === 'edit'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
<Edit3 className="w-4 h-4 inline-block mr-1" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('preview')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
||||
viewMode === 'preview'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-4 h-4 inline-block mr-1" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('split')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
||||
viewMode === 'split'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
<Columns className="w-4 h-4 inline-block mr-1" />
|
||||
Split
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onSave && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
isLoading={isSaving}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<MenuButton
|
||||
onClick={() => setShowHelp(true)}
|
||||
title="Help & Keyboard Shortcuts"
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
||||
active={isFullscreen}
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
active={editor.isActive('bold')}
|
||||
title="Bold"
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive('italic')}
|
||||
title="Italic"
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
{/* Formatting Toolbar */}
|
||||
{viewMode !== 'preview' && (
|
||||
<div className="flex items-center gap-1 p-2 flex-wrap">
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
active={editor.isActive('heading', { level: 1 })}
|
||||
title="Heading 1 (Ctrl+Alt+1)"
|
||||
>
|
||||
<Heading1 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
title="Heading 2 (Ctrl+Alt+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 (Ctrl+Alt+3)"
|
||||
>
|
||||
<Heading3 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
|
||||
active={editor.isActive('heading', { level: 4 })}
|
||||
title="Heading 4 (Ctrl+Alt+4)"
|
||||
>
|
||||
<Heading4 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 5 }).run()}
|
||||
active={editor.isActive('heading', { level: 5 })}
|
||||
title="Heading 5 (Ctrl+Alt+5)"
|
||||
>
|
||||
<Heading5 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 6 }).run()}
|
||||
active={editor.isActive('heading', { level: 6 })}
|
||||
title="Heading 6 (Ctrl+Alt+6)"
|
||||
>
|
||||
<Heading6 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
active={editor.isActive('bulletList')}
|
||||
title="Bullet List"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
active={editor.isActive('orderedList')}
|
||||
title="Ordered List"
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
active={editor.isActive('bold')}
|
||||
title="Bold (Ctrl+B)"
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive('italic')}
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
active={editor.isActive('code')}
|
||||
title="Inline Code (Ctrl+E)"
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
active={editor.isActive('codeBlock')}
|
||||
title="Code Block (Ctrl+Alt+C)"
|
||||
>
|
||||
<Code2 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => setShowLinkDialog(true)}
|
||||
active={editor.isActive('link')}
|
||||
title="Add Link"
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
active={editor.isActive('bulletList')}
|
||||
title="Bullet List (Ctrl+Shift+8)"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
active={editor.isActive('orderedList')}
|
||||
title="Numbered List (Ctrl+Shift+9)"
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
active={editor.isActive('blockquote')}
|
||||
title="Blockquote (Ctrl+Shift+B)"
|
||||
>
|
||||
<Quote className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
title="Undo"
|
||||
>
|
||||
<Undo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
title="Redo"
|
||||
>
|
||||
<Redo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => setShowLinkDialog(true)}
|
||||
active={editor.isActive('link')}
|
||||
title="Add Link (Ctrl+K)"
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
title="Horizontal Rule"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
active={editor.isActive({ textAlign: 'left' })}
|
||||
title="Align Left"
|
||||
>
|
||||
<AlignLeft className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
active={editor.isActive({ textAlign: 'center' })}
|
||||
title="Align Center"
|
||||
>
|
||||
<AlignCenter className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
active={editor.isActive({ textAlign: 'right' })}
|
||||
title="Align Right"
|
||||
>
|
||||
<AlignRight className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
|
||||
active={editor.isActive({ textAlign: 'justify' })}
|
||||
title="Justify"
|
||||
>
|
||||
<AlignJustify className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
||||
title="Clear Formatting"
|
||||
>
|
||||
<RemoveFormatting className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<Undo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="Redo (Ctrl+Y)"
|
||||
>
|
||||
<Redo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Link Dialog */}
|
||||
{showLinkDialog && (
|
||||
<div className="p-3 bg-primary-50 border-b border-primary-200 flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<Button size="sm" onClick={addLink}>Add Link</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
setShowLinkDialog(false);
|
||||
setLinkUrl('');
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Editor */}
|
||||
{viewMode !== 'preview' && (
|
||||
<div className={`${viewMode === 'split' ? 'w-1/2 border-r border-neutral-200' : 'w-full'} overflow-auto`}>
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="min-h-[400px] p-4 prose prose-neutral max-w-none focus:outline-none [&_.ProseMirror]:min-h-[400px] [&_.ProseMirror]:outline-none [&_.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 [&_.ProseMirror_br.hard-break]:display-block [&_.ProseMirror_br.hard-break]:content-[''] [&_.ProseMirror_br.hard-break]:margin-[0.5em_0] [&_.ProseMirror_pre]:bg-neutral-100 [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_code]:bg-neutral-100 [&_.ProseMirror_code]:rounded [&_.ProseMirror_code]:px-1 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:text-sm [&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{viewMode !== 'edit' && (
|
||||
<div className={`${viewMode === 'split' ? 'w-1/2' : 'w-full'} overflow-auto bg-neutral-50 p-4`}>
|
||||
<div
|
||||
className="prose prose-neutral max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: getPreviewContent() }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-neutral-50 border-t border-neutral-200 text-sm text-neutral-600">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>{wordCount} words</span>
|
||||
<span>{charCount} characters</span>
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
Press Shift+Enter for line break, Enter for new paragraph
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link Dialog */}
|
||||
{showLinkDialog && (
|
||||
<div className="p-3 bg-primary-50 border-b border-primary-200 flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<Button size="sm" onClick={addLink}>Add Link</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
setShowLinkDialog(false);
|
||||
setLinkUrl('');
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
{/* Help Modal */}
|
||||
{showHelp && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[80vh] overflow-auto">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Editor Help & Keyboard Shortcuts</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Text Formatting</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><kbd>Ctrl+B</kbd> - Bold</div>
|
||||
<div><kbd>Ctrl+I</kbd> - Italic</div>
|
||||
<div><kbd>Ctrl+E</kbd> - Inline code</div>
|
||||
<div><kbd>Ctrl+K</kbd> - Add link</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Headings</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><kbd>Ctrl+Alt+1</kbd> - Heading 1</div>
|
||||
<div><kbd>Ctrl+Alt+2</kbd> - Heading 2</div>
|
||||
<div><kbd>Ctrl+Alt+3</kbd> - Heading 3</div>
|
||||
<div><kbd>Ctrl+Alt+4</kbd> - Heading 4</div>
|
||||
<div><kbd>Ctrl+Alt+5</kbd> - Heading 5</div>
|
||||
<div><kbd>Ctrl+Alt+6</kbd> - Heading 6</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Lists & Blocks</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><kbd>Ctrl+Shift+8</kbd> - Bullet list</div>
|
||||
<div><kbd>Ctrl+Shift+9</kbd> - Numbered list</div>
|
||||
<div><kbd>Ctrl+Shift+B</kbd> - Blockquote</div>
|
||||
<div><kbd>Ctrl+Alt+C</kbd> - Code block</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Text Alignment</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>Click alignment buttons in toolbar</div>
|
||||
<div>Works on paragraphs and headings</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Line Breaks</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div><kbd>Enter</kbd> - New paragraph</div>
|
||||
<div><kbd>Shift+Enter</kbd> - Line break (preserves formatting)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Navigation</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><kbd>Ctrl+Z</kbd> - Undo</div>
|
||||
<div><kbd>Ctrl+Y</kbd> - Redo</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button onClick={() => setShowHelp(false)}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor */}
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="min-h-[300px] p-4 prose prose-neutral max-w-none focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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<string>('impressum');
|
||||
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
|
||||
const [editForm, setEditForm] = useState<Partial<CMSPageType>>({});
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(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<CMSPageType> }) =>
|
||||
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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loading size="lg" text={t('cms.loadingPages')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentPage = pages?.find(p => p.slug === selectedPage);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900">{t('cms.title')}</h1>
|
||||
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Page Selection */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('cms.pages')}</h2>
|
||||
<div className="space-y-2">
|
||||
{pages?.map((page) => (
|
||||
<button
|
||||
key={page.slug}
|
||||
onClick={() => {
|
||||
if (hasUnsavedChanges) {
|
||||
if (confirm('You have unsaved changes. Do you want to save them?')) {
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
setSelectedPage(page.slug);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
|
||||
selectedPage === page.slug
|
||||
? 'bg-primary-100 text-primary-700 border border-primary-300'
|
||||
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-5 h-5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{t(`legal.${page.slug}`)}</p>
|
||||
<p className="text-sm text-neutral-500">/{page.slug}</p>
|
||||
</div>
|
||||
{selectedPage === page.slug && hasUnsavedChanges && (
|
||||
<div className="w-2 h-2 bg-yellow-500 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="md" className="mt-4">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 mb-3">{t('cms.previewLinks')}</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<a
|
||||
href={`${window.location.origin}/${selectedPage}?lang=en`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
{t('cms.englishVersion')}
|
||||
</a>
|
||||
<a
|
||||
href={`${window.location.origin}/${selectedPage}?lang=de`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
{t('cms.germanVersion')}
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Auto-save status */}
|
||||
{(hasUnsavedChanges || lastSaved) && (
|
||||
<Card padding="md" className="mt-4">
|
||||
<div className="text-sm">
|
||||
{isAutoSaving && (
|
||||
<div className="flex items-center gap-2 text-neutral-600">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
Auto-saving...
|
||||
</div>
|
||||
)}
|
||||
{!isAutoSaving && hasUnsavedChanges && (
|
||||
<div className="flex items-center gap-2 text-yellow-600">
|
||||
<div className="w-2 h-2 bg-yellow-500 rounded-full" />
|
||||
Unsaved changes
|
||||
</div>
|
||||
)}
|
||||
{!hasUnsavedChanges && lastSaved && (
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<Clock className="w-4 h-4" />
|
||||
Saved {new Date(lastSaved).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="lg:col-span-3">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">
|
||||
{t('cms.editPage', { page: t(`legal.${selectedPage}`) })}
|
||||
</h2>
|
||||
|
||||
{/* Language Tabs */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditingLang('en')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
editingLang === 'en'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
||||
}`}
|
||||
>
|
||||
🇬🇧 English
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingLang('de')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
editingLang === 'de'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
||||
}`}
|
||||
>
|
||||
🇩🇪 Deutsch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('cms.pageTitle')} ({editingLang === 'en' ? 'English' : 'German'})
|
||||
</label>
|
||||
<Input
|
||||
value={editingLang === 'en' ? editForm.title_en || '' : editForm.title_de || ''}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder={t('cms.pageTitlePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('cms.pageContent')} ({editingLang === 'en' ? 'English' : 'German'})
|
||||
</label>
|
||||
<CMSEditor
|
||||
content={editingLang === 'en' ? editForm.content_en || '' : editForm.content_de || ''}
|
||||
onChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
isSaving={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentPage?.updated_at && (
|
||||
<p className="text-xs text-neutral-500 mt-4">
|
||||
{t('cms.lastUpdated')} {new Date(currentPage.updated_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 = () => {
|
||||
|
||||
<div
|
||||
className="prose prose-neutral max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(page.content) }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(page.content, {
|
||||
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
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
</Card>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user