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 }) {
+ {showPreview &&
}
{showPreview && (
-
+
{previewUrl
?
:
Auf „Vorschau“ klicken — die Seite erscheint hier in deinem echten Theme.
}
@@ -281,66 +284,84 @@ function Editor({ initial, onSaved, onMsg }) {
);
}
-// ── Markdown-Editor mit Formatierungs-Toolbar ──────────────────────────────
-function MarkdownEditor({ value, onChange, onUpload, onMsg }) {
- const ta = useRef(null);
- const fileIn = useRef(null);
+// ── WYSIWYG-Editor (Toast UI, vanilla) — Formatierung live, speichert Markdown ──
+function RichEditor({ value, onChange, onUpload }) {
+ const el = useRef(null);
+ const inst = useRef(null);
+ // value/onChange/onUpload in Refs, damit der Editor nur EINMAL erzeugt wird.
+ const cb = useRef({ onChange, onUpload });
+ cb.current = { onChange, onUpload };
- function restore(start, end) {
- requestAnimationFrame(() => {
- const el = ta.current; if (!el) return;
- el.focus(); el.selectionStart = start; el.selectionEnd = end;
+ useEffect(() => {
+ inst.current = new ToastEditor({
+ el: el.current,
+ initialValue: value || '',
+ initialEditType: 'wysiwyg',
+ previewStyle: 'tab',
+ height: '100%',
+ usageStatistics: false,
+ autofocus: false,
+ toolbarItems: [
+ ['heading', 'bold', 'italic', 'strike'],
+ ['hr', 'quote'],
+ ['ul', 'ol'],
+ ['link', 'image'],
+ ['code', 'codeblock'],
+ ],
+ hooks: {
+ addImageBlobHook: async (blob, done) => {
+ try { done(await cb.current.onUpload(blob), blob.name || 'bild'); }
+ catch { /* Upload fehlgeschlagen */ }
+ },
+ },
+ events: { change: () => cb.current.onChange(inst.current.getMarkdown()) },
});
- }
- function wrap(pre, post = pre, placeholder = '') {
- const el = ta.current; const s = el.selectionStart, e = el.selectionEnd;
- const sel = value.slice(s, e) || placeholder;
- onChange(value.slice(0, s) + pre + sel + post + value.slice(e));
- restore(s + pre.length, s + pre.length + sel.length);
- }
- function linePrefix(prefix) {
- const el = ta.current; const s = el.selectionStart;
- const ls = value.lastIndexOf('\n', s - 1) + 1;
- onChange(value.slice(0, ls) + prefix + value.slice(ls));
- restore(s + prefix.length, s + prefix.length);
- }
- function insert(text) {
- const el = ta.current; const s = el.selectionStart, e = el.selectionEnd;
- onChange(value.slice(0, s) + text + value.slice(e));
- restore(s + text.length, s + text.length);
- }
- async function pickImage(ev) {
+ return () => { inst.current?.destroy(); inst.current = null; };
+ }, []);
+
+ return
;
+}
+
+// ── Profil ──────────────────────────────────────────────────────────────────
+function Profile({ onMsg }) {
+ const [p, setP] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const fileIn = useRef(null);
+ useEffect(() => { api.getProfile().then(setP).catch((e) => onMsg({ type: 'err', text: e.message })); }, []);
+ if (!p) return
…
;
+ const set = (k) => (e) => setP({ ...p, [k]: e.target.value });
+
+ async function pickAvatar(ev) {
const file = ev.target.files?.[0]; ev.target.value = '';
if (!file) return;
- try { insert(`![${file.name.replace(/\.[^.]+$/, '')}](${await onUpload(file)})`); }
- catch (e) { onMsg?.({ type: 'err', text: e.message }); }
+ setBusy(true);
+ try { const { url } = await api.upload(file); setP((x) => ({ ...x, avatar: url })); }
+ catch (e) { onMsg({ type: 'err', text: e.message }); }
+ finally { setBusy(false); }
+ }
+ async function save() {
+ setBusy(true);
+ try { await api.saveProfile({ name: p.name, bio: p.bio, avatar: p.avatar }); onMsg({ type: 'ok', text: 'Profil gespeichert.' }); }
+ catch (e) { onMsg({ type: 'err', text: e.message }); }
+ finally { setBusy(false); }
}
- const B = ({ on, title, children }) => (
-
- );
-
return (
-
-
-
wrap('**', '**', 'fett')}>B
-
wrap('*', '*', 'kursiv')}>I
-
wrap('', '', 'unterstrichen')}>U
-
-
linePrefix('## ')}>H2
-
linePrefix('### ')}>H3
-
-
wrap('[', '](https://)', 'Linktext')}>🔗
-
fileIn.current?.click()}>🖼
-
-
linePrefix('- ')}>• Liste
-
linePrefix('1. ')}>1.
-
linePrefix('> ')}>❝
-
wrap('`', '`', 'code')}>{'>'}
-
+
+
+
Profil
+
+
{!p.avatar && '🙂'}
+
+
+
+
{p.email}
+
+
+
+
+
-
);
}
diff --git a/cms/admin/src/api.js b/cms/admin/src/api.js
index de46706..bfc9015 100644
--- a/cms/admin/src/api.js
+++ b/cms/admin/src/api.js
@@ -41,4 +41,6 @@ export const api = {
preview: (path) => call('/preview', { method: 'POST', body: JSON.stringify({ path }) }),
publish: (path) => call('/publish', { method: 'POST', body: JSON.stringify({ path }) }),
upload: uploadFile,
+ getProfile: () => call('/profile'),
+ saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
};
diff --git a/cms/admin/src/styles.css b/cms/admin/src/styles.css
index 0978847..23607e5 100644
--- a/cms/admin/src/styles.css
+++ b/cms/admin/src/styles.css
@@ -22,37 +22,27 @@
--amber: #b8902f;
--radius: 11px;
+ --pill: 22px;
--shadow: 0 10px 34px -22px rgba(40,20,10,.5);
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
-body {
- margin: 0; font-family: var(--sans); font-size: 14.5px;
- color: var(--text); background: var(--bg);
-}
+body { margin: 0; font-family: var(--sans); font-size: 14.5px; color: var(--text); background: var(--bg); }
button, input, select, textarea { font-family: inherit; font-size: inherit; color: var(--text); }
.muted { color: var(--muted); }
.center { display: grid; place-items: center; height: 100%; }
/* ── Login ── */
-.login {
- background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
- padding: 36px 32px; width: 320px; display: flex; flex-direction: column; gap: 12px; box-shadow: var(--shadow);
-}
+.login { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 36px 32px; width: 320px; display: flex; flex-direction: column; gap: 12px; box-shadow: var(--shadow); }
.login-brand { font-family: var(--display); font-weight: 700; letter-spacing: .14em; font-size: 20px; }
.login-sub { font-family: var(--serif); font-style: italic; color: var(--muted); margin-bottom: 10px; }
.err { color: var(--accent); margin: 4px 0 0; font-size: 13px; }
-/* ── Inputs / Buttons ── */
-input, select, textarea {
- background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 9px 11px; width: 100%;
-}
+/* ── Inputs / Buttons (Pill) ── */
+input, select, textarea { background: var(--panel); border: 1px solid var(--line); border-radius: 9px; padding: 9px 11px; width: 100%; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent-soft); box-shadow: 0 0 0 3px rgba(181,74,44,.12); }
-button {
- background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
- padding: 8px 15px; cursor: pointer; font-weight: 500; transition: .12s; white-space: nowrap;
-}
+button { background: var(--panel); border: 1px solid var(--line); border-radius: var(--pill); padding: 8px 16px; cursor: pointer; font-weight: 500; transition: .12s; white-space: nowrap; }
button:hover { border-color: var(--accent-soft); }
button:disabled { opacity: .5; cursor: default; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
@@ -60,30 +50,33 @@ button.primary:hover { background: #a23f23; }
button.ghost { background: transparent; border-color: transparent; color: var(--dark-muted); }
button.ghost:hover { color: #fff; border-color: var(--dark-muted); }
-/* ── App / Topbar (schwarz wie der Site-Masthead) ── */
+/* ── Topbar (schwarz wie Site-Masthead) ── */
.app { display: flex; flex-direction: column; height: 100%; }
.topbar { display: flex; align-items: center; gap: 12px; padding: 0 18px; height: 54px; background: var(--dark); color: var(--dark-text); flex: none; }
.topbar .logo { font-family: var(--display); font-weight: 700; letter-spacing: .14em; }
.topbar .logo-sub { font-family: var(--serif); font-style: italic; color: var(--dark-muted); font-size: 13px; }
.topbar .spacer { flex: 1; }
.topbar .who { color: var(--dark-muted); font-size: 13px; }
+.nav { display: flex; gap: 4px; margin-left: 16px; }
+.nav button { background: transparent; border: none; color: var(--dark-muted); padding: 6px 15px; border-radius: var(--pill); }
+.nav button:hover { color: #fff; }
+.nav button.active { background: rgba(255,255,255,.12); color: #fff; }
.body { display: flex; flex: 1; min-height: 0; }
-/* ── Sidebar (Collections, Sveltia-artig) ── */
+/* ── Sidebar ── */
aside { width: 290px; flex: none; border-right: 1px solid var(--line); background: var(--panel-2); padding: 14px; overflow: auto; }
.new { width: 100%; margin-bottom: 12px; background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
.new:hover { background: #a23f23; }
-.search { display: flex; align-items: center; gap: 7px; background: var(--panel); border: 1px solid var(--line); border-radius: 9px; padding: 0 11px; margin-bottom: 16px; }
+.search { display: flex; align-items: center; gap: 7px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--pill); padding: 0 13px; margin-bottom: 16px; }
.search span { color: var(--muted); font-size: 17px; }
.search input { border: none; background: transparent; padding: 9px 0; }
.search input:focus { box-shadow: none; }
-
.group { margin-bottom: 18px; }
.group-title { display: flex; align-items: center; gap: 7px; font-family: var(--display); font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin: 0 6px 8px; }
.group-title span { background: var(--line); color: var(--muted); border-radius: 20px; padding: 1px 7px; font-size: 10px; letter-spacing: 0; }
.list { list-style: none; margin: 0; padding: 0; }
-.list li { display: flex; align-items: center; gap: 10px; padding: 9px; border-radius: 9px; cursor: pointer; }
+.list li { display: flex; align-items: center; gap: 10px; padding: 9px; border-radius: 10px; cursor: pointer; }
.list li:hover { background: var(--panel); }
.list li.active { background: var(--panel); box-shadow: inset 3px 0 0 var(--accent), var(--shadow); }
.list .dot { width: 10px; height: 10px; border-radius: 50%; flex: none; border: 1px solid rgba(0,0,0,.12); }
@@ -99,46 +92,54 @@ main { flex: 1; min-width: 0; }
/* ── Editor ── */
.editor { display: flex; height: 100%; }
.editor-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
-.editor-head {
- display: flex; align-items: center; gap: 9px; padding: 11px 22px;
- border-bottom: 1px solid var(--line); background: var(--panel); flex: none;
-}
+.editor-head { display: flex; align-items: center; gap: 9px; padding: 11px 22px; border-bottom: 1px solid var(--line); background: var(--panel); flex: none; }
.editor-head .crumb { font-family: var(--mono); font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.editor-head .spacer { flex: 1; }
.editor-head .toggle { background: transparent; border-color: transparent; color: var(--muted); }
.editor-head .toggle:hover { color: var(--text); border-color: var(--line); }
-.status { font-size: 11px; border-radius: 20px; padding: 3px 10px; font-weight: 600; }
+.status { font-size: 11px; border-radius: var(--pill); padding: 3px 11px; font-weight: 600; }
.status.draft { color: var(--amber); background: rgba(184,144,47,.12); }
.status.live { color: var(--ok); background: rgba(93,125,75,.14); }
-.fields { flex: 1; padding: 22px 24px; overflow: auto; display: flex; flex-direction: column; gap: 14px; }
+/* Metadaten kompakt oben, Schreibfeld groß darunter */
+.fields { flex: 1; min-height: 0; padding: 18px 22px; overflow: auto; display: flex; flex-direction: column; gap: 12px; }
.row { display: flex; gap: 12px; align-items: flex-end; }
-.path-row { display: flex; gap: 12px; align-items: flex-end; }
-.path-row:empty { display: none; }
+.meta { display: flex; flex-wrap: wrap; gap: 12px 14px; align-items: flex-end; }
label { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--muted); flex: 1; }
-label.sm { flex: 0 0 150px; } label.xs { flex: 0 0 110px; }
-label.check { flex-direction: row; align-items: center; gap: 7px; flex: 0 0 auto; white-space: nowrap; }
+.meta label { flex: 0 0 auto; }
+.meta label.sm { width: 160px; } .meta label.xs { width: 100px; } .meta label:not(.sm):not(.xs):not(.check) { flex: 1; min-width: 140px; }
+label.check { flex-direction: row; align-items: center; gap: 7px; white-space: nowrap; padding-bottom: 9px; }
label.check input { width: auto; }
label.big input { font-family: var(--serif); font-size: 23px; font-weight: 600; padding: 11px 13px; }
-
.colorpick { display: flex; align-items: center; gap: 8px; }
.colorpick .swatch { width: 22px; height: 22px; border-radius: 6px; border: 1px solid rgba(0,0,0,.15); flex: none; }
.colorpick select { flex: 1; }
-/* ── Markdown-Editor ── */
-.md { display: flex; flex-direction: column; flex: 1; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: var(--panel); box-shadow: var(--shadow); }
-.toolbar { display: flex; align-items: center; gap: 3px; flex-wrap: wrap; padding: 7px 9px; border-bottom: 1px solid var(--line); background: var(--panel-2); }
-.tb { padding: 5px 9px; border: 1px solid transparent; background: transparent; border-radius: 6px; min-width: 30px; font-size: 13px; line-height: 1; }
-.tb:hover { background: var(--panel); border-color: var(--line); }
-.toolbar .sep { width: 1px; height: 18px; background: var(--line); margin: 0 4px; }
-.md textarea { border: none; border-radius: 0; min-height: 320px; flex: 1; resize: vertical; font-family: var(--serif); font-size: 16px; line-height: 1.7; padding: 16px; }
-.md textarea:focus { box-shadow: none; }
+/* WYSIWYG-Editor füllt den meisten Platz */
+.rich { flex: 1; min-height: 460px; display: flex; flex-direction: column; }
+.rich-host { flex: 1; min-height: 0; }
+.rich .toastui-editor-defaultUI { height: 100%; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); font-family: var(--sans); }
+.toastui-editor-contents { font-family: var(--serif); font-size: 16px; }
+.toastui-editor-defaultUI-toolbar { background: var(--panel-2); }
+.toastui-editor-toolbar { border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
-/* ── Vorschau-Pane ── */
-.preview { width: 46%; flex: none; border-left: 1px solid var(--line); background: #fff; }
+/* ── Ziehbarer Trenner + Vorschau ── */
+.splitter { width: 7px; flex: none; cursor: col-resize; background: var(--line); }
+.splitter:hover { background: var(--accent-soft); }
+.preview { flex: none; background: #fff; }
.preview iframe { width: 100%; height: 100%; border: 0; }
+/* ── Profil ── */
+.profile { width: 100%; overflow: auto; display: flex; justify-content: center; padding: 44px 20px; }
+.profile-card { width: 100%; max-width: 560px; height: max-content; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 28px 30px; display: flex; flex-direction: column; gap: 16px; }
+.profile-card h2 { font-family: var(--serif); margin: 0 0 4px; font-weight: 600; }
+.avatar-row { display: flex; align-items: center; gap: 18px; }
+.avatar { width: 92px; height: 92px; border-radius: 50%; background: var(--panel-2) center/cover no-repeat; border: 1px solid var(--line); display: grid; place-items: center; font-size: 34px; flex: none; }
+.who-mail { font-size: 12px; margin: 9px 0 0; }
+.profile-card textarea { font-family: var(--serif); font-size: 15px; line-height: 1.6; resize: vertical; }
+.profile-card .actions { display: flex; }
+
/* ── Toast ── */
-.toast { position: fixed; bottom: 20px; right: 20px; padding: 11px 18px; border-radius: 9px; color: #fff; cursor: pointer; box-shadow: 0 10px 30px -12px rgba(0,0,0,.4); font-size: 13.5px; max-width: 380px; }
+.toast { position: fixed; bottom: 20px; right: 20px; padding: 11px 18px; border-radius: 11px; color: #fff; cursor: pointer; box-shadow: 0 10px 30px -12px rgba(0,0,0,.4); font-size: 13.5px; max-width: 380px; z-index: 50; }
.toast.ok { background: var(--ok); }
.toast.err { background: var(--accent); }
diff --git a/cms/api/src/index.js b/cms/api/src/index.js
index 93b5179..3fab169 100644
--- a/cms/api/src/index.js
+++ b/cms/api/src/index.js
@@ -6,6 +6,7 @@ import content from './routes/content.js';
import preview from './routes/preview.js';
import publish from './routes/publish.js';
import upload from './routes/upload.js';
+import profile from './routes/profile.js';
import { requireAuth } from './auth.js';
const SITE_DIR = process.env.SITE_DIR || '/site';
@@ -22,6 +23,7 @@ app.route('/api/content', content);
app.route('/api/preview', preview);
app.route('/api/publish', publish);
app.route('/api/upload', upload);
+app.route('/api/profile', profile);
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
app.get('/admin', (c) => c.redirect('/admin/'));
diff --git a/cms/api/src/routes/profile.js b/cms/api/src/routes/profile.js
new file mode 100644
index 0000000..0053d44
--- /dev/null
+++ b/cms/api/src/routes/profile.js
@@ -0,0 +1,32 @@
+import { Hono } from 'hono';
+import { readFile, writeFile, mkdir } from 'node:fs/promises';
+import path from 'node:path';
+
+// Profile als Hugo-Data-Datei: data/authors.json (Map E-Mail → Profil).
+// So kann das Theme die Autor:innen via site.Data.authors rendern.
+const SITE_DIR = process.env.SITE_DIR || '/site';
+const FILE = path.join(SITE_DIR, 'data', 'authors.json');
+
+async function readAll() {
+ try { return JSON.parse(await readFile(FILE, 'utf8')); } catch { return {}; }
+}
+
+const profile = new Hono();
+
+profile.get('/', async (c) => {
+ const email = c.get('user')?.email || 'default';
+ const all = await readAll();
+ return c.json({ email, name: '', bio: '', avatar: '', ...(all[email] || {}) });
+});
+
+profile.put('/', async (c) => {
+ const email = c.get('user')?.email || 'default';
+ const { name, bio, avatar } = await c.req.json();
+ const all = await readAll();
+ all[email] = { name: name || '', bio: bio || '', avatar: avatar || '' };
+ await mkdir(path.dirname(FILE), { recursive: true });
+ await writeFile(FILE, JSON.stringify(all, null, 2) + '\n', 'utf8');
+ return c.json({ ok: true });
+});
+
+export default profile;