From 35c2a122ae7603b0dbefac7b5bc0421d9e7f989d Mon Sep 17 00:00:00 2001 From: karim Date: Sun, 31 May 2026 12:10:31 +0200 Subject: [PATCH] cms: WYSIWYG-Editor (Toast UI), Profilseite, ziehbare Vorschau, Pill-Optik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - echtes WYSIWYG statt Markdown-Sterne: Formatierung live, speichert Markdown, Bild-Upload via Toolbar. Editor nimmt den meisten Platz, Metadaten kompakt oben. - Vorschau standardmäßig aus (kein toter Raum) + ziehbarer Trenner Editor↔Vorschau. - Profilseite (Nav Inhalte/Profil): Profilbild-Upload + Kurztext, gespeichert als data/authors.json (vom Theme via site.Data.authors nutzbar). - mehr Pill-Optik (Buttons, Suche, Chips), schwarze Topbar-Navigation. Co-Authored-By: Claude Opus 4.8 --- cms/admin/package-lock.json | 124 ++++++++++++++ cms/admin/package.json | 1 + cms/admin/src/App.jsx | 297 ++++++++++++++++++---------------- cms/admin/src/api.js | 2 + cms/admin/src/styles.css | 87 +++++----- cms/api/src/index.js | 2 + cms/api/src/routes/profile.js | 32 ++++ 7 files changed, 364 insertions(+), 181 deletions(-) create mode 100644 cms/api/src/routes/profile.js diff --git a/cms/admin/package-lock.json b/cms/admin/package-lock.json index 1a78340..a9fa815 100644 --- a/cms/admin/package-lock.json +++ b/cms/admin/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@supabase/supabase-js": "^2.47.10", + "@toast-ui/editor": "^3.2.2", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -1271,6 +1272,22 @@ "node": ">=20.0.0" } }, + "node_modules/@toast-ui/editor": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@toast-ui/editor/-/editor-3.2.2.tgz", + "integrity": "sha512-ASX7LFjN2ZYQJrwmkUajPs7DRr9FsM1+RQ82CfTO0Y5ZXorBk1VZS4C2Dpxinx9kl55V4F8/A2h2QF4QMDtRbA==", + "license": "MIT", + "dependencies": { + "dompurify": "^2.3.3", + "prosemirror-commands": "^1.1.9", + "prosemirror-history": "^1.1.3", + "prosemirror-inputrules": "^1.1.3", + "prosemirror-keymap": "^1.1.4", + "prosemirror-model": "^1.14.1", + "prosemirror-state": "^1.3.4", + "prosemirror-view": "^1.18.7" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1437,6 +1454,12 @@ } } }, + "node_modules/dompurify": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz", + "integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==", + "license": "(MPL-2.0 OR Apache-2.0)" + }, "node_modules/electron-to-chromium": { "version": "1.5.364", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", @@ -1638,6 +1661,12 @@ "node": ">=18" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1687,6 +1716,89 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.7", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.7.tgz", + "integrity": "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.8", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", + "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1767,6 +1879,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -1925,6 +2043,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/cms/admin/package.json b/cms/admin/package.json index 4dba281..99f1939 100644 --- a/cms/admin/package.json +++ b/cms/admin/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@supabase/supabase-js": "^2.47.10", + "@toast-ui/editor": "^3.2.2", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/cms/admin/src/App.jsx b/cms/admin/src/App.jsx index c26fe09..60d8dd4 100644 --- a/cms/admin/src/App.jsx +++ b/cms/admin/src/App.jsx @@ -1,4 +1,6 @@ import { useEffect, useRef, useState } from 'react'; +import ToastEditor from '@toast-ui/editor'; +import '@toast-ui/editor/dist/toastui-editor.css'; import { supabase } from './supabase.js'; import { api } from './api.js'; @@ -32,13 +34,11 @@ const EMPTY = { export default function App() { const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); - useEffect(() => { supabase.auth.getSession().then(({ data }) => { setSession(data.session); setLoading(false); }); const { data: sub } = supabase.auth.onAuthStateChange((_e, s) => setSession(s)); return () => sub.subscription.unsubscribe(); }, []); - if (loading) return
; if (!session) return ; return ; @@ -71,6 +71,7 @@ function Dashboard({ email }) { const [entries, setEntries] = useState([]); const [current, setCurrent] = useState(null); const [query, setQuery] = useState(''); + const [view, setView] = useState('content'); const [msg, setMsg] = useState(null); async function refresh() { @@ -86,9 +87,7 @@ function Dashboard({ email }) { } const q = query.trim().toLowerCase(); - const filtered = q - ? entries.filter((e) => e.title.toLowerCase().includes(q) || (e.section || '').includes(q)) - : entries; + const filtered = q ? entries.filter((e) => e.title.toLowerCase().includes(q) || (e.section || '').includes(q)) : entries; const groups = { beitrag: [], seite: [], rubrik: [] }; for (const e of filtered) (groups[e.kind] || groups.seite).push(e); @@ -97,43 +96,49 @@ function Dashboard({ email }) {
OPENBUREAU Redaktion + {email}
- - -
- {current - ? { setCurrent(loaded); refresh(); }} onMsg={setMsg} /> - :

Wähle links einen Eintrag — oder leg einen neuen Beitrag an.

} -
+ {view === 'profile' ? ( + + ) : ( + <> + +
+ {current + ? { setCurrent(loaded); refresh(); }} onMsg={setMsg} /> + :

Wähle links einen Eintrag — oder leg einen neuen Beitrag an.

} +
+ + )}
{msg &&
setMsg(null)}>{msg.text}
} @@ -144,10 +149,27 @@ function Dashboard({ email }) { function Editor({ initial, onSaved, onMsg }) { const [f, setF] = useState(initial); const [previewUrl, setPreviewUrl] = useState(null); - const [showPreview, setShowPreview] = useState(true); + const [showPreview, setShowPreview] = useState(false); + const [pw, setPw] = useState(44); const [busy, setBusy] = useState(false); + const editorRef = useRef(null); + const dragging = useRef(false); const set = (k) => (e) => setF({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }); + // Ziehbarer Trenner Editor ↔ Vorschau. + useEffect(() => { + function move(e) { + if (!dragging.current || !editorRef.current) return; + const r = editorRef.current.getBoundingClientRect(); + setPw(Math.min(70, Math.max(25, ((r.right - e.clientX) / r.width) * 100))); + } + function up() { dragging.current = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; } + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); }; + }, []); + function startDrag(e) { dragging.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); } + function currentPath() { if (!f.isNew) return f.path; const slug = (f.slug || '').trim(); @@ -169,19 +191,15 @@ function Editor({ initial, onSaved, onMsg }) { } catch (e) { onMsg({ type: 'err', text: e.message }); return null; } finally { setBusy(false); } } - async function preview() { - const path = await save(); - if (!path) return; + const path = await save(); if (!path) return; setShowPreview(true); setBusy(true); try { const res = await api.preview(path); setPreviewUrl(`${res.url}?t=${Date.now()}`); } catch (e) { onMsg({ type: 'err', text: e.message }); } finally { setBusy(false); } } - async function publish() { - const path = await save(); - if (!path) return; + const path = await save(); if (!path) return; if (!confirm('Diesen Eintrag live publizieren?')) return; setBusy(true); try { const res = await api.publish(path); onMsg({ type: 'ok', text: `Live: ${res.url}` }); } @@ -190,14 +208,12 @@ function Editor({ initial, onSaved, onMsg }) { } return ( -
+
{f.isNew ? 'Neuer Eintrag' : f.path}
- {f.draft - ? Entwurf - : Veröffentlicht} + {f.draft ? Entwurf : Veröffentlicht} @@ -207,71 +223,58 @@ function Editor({ initial, onSaved, onMsg }) {
-
- {f.isNew ? ( - <> -
+ )} -
+
-
- -
- +
-
- setF((p) => ({ ...p, body }))} - onUpload={async (file) => (await api.upload(file)).url} - onMsg={onMsg} - /> +
+ setF((p) => ({ ...p, body }))} + onUpload={async (file) => (await api.upload(file)).url} /> +
+ {showPreview &&
} {showPreview && ( -
+
{previewUrl ?