cms: WYSIWYG-Editor (Toast UI), Profilseite, ziehbare Vorschau, Pill-Optik

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 12:10:31 +02:00
parent c780decdc3
commit 35c2a122ae
7 changed files with 364 additions and 181 deletions
+124
View File
@@ -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",
+1
View File
@@ -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"
},
+159 -138
View File
@@ -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 <div className="center muted"></div>;
if (!session) return <Login />;
return <Dashboard email={session.user.email} />;
@@ -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 }) {
<header className="topbar">
<span className="logo">OPENBUREAU</span>
<span className="logo-sub">Redaktion</span>
<nav className="nav">
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
</nav>
<span className="spacer" />
<span className="who">{email}</span>
<button className="ghost" onClick={() => supabase.auth.signOut()}>Abmelden</button>
</header>
<div className="body">
<aside>
<button className="new" onClick={() => setCurrent({ ...EMPTY })}> Neuer Beitrag</button>
<div className="search">
<span></span>
<input placeholder="Suchen…" value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
{['beitrag', 'seite', 'rubrik'].map((kind) => groups[kind].length > 0 && (
<div className="group" key={kind}>
<div className="group-title">{KIND_LABEL[kind]} <span>{groups[kind].length}</span></div>
<ul className="list">
{groups[kind].map((e) => (
<li key={e.path} className={current?.path === e.path ? 'active' : ''} onClick={() => open(e)}>
<span className="dot" style={{ background: e.color ? hexOf(e.color) : 'var(--line)' }} />
<span className="t">
<span className="t-title">{e.title}</span>
<span className="t-meta">{[e.section, e.date].filter(Boolean).join(' · ')}</span>
</span>
{e.draft && <span className="draft-tag">Entwurf</span>}
</li>
))}
</ul>
</div>
))}
</aside>
<main>
{current
? <Editor key={current.path || 'new'} initial={current}
onSaved={(loaded) => { setCurrent(loaded); refresh(); }} onMsg={setMsg} />
: <div className="empty"><p>Wähle links einen Eintrag oder leg einen neuen Beitrag an.</p></div>}
</main>
{view === 'profile' ? (
<Profile onMsg={setMsg} />
) : (
<>
<aside>
<button className="new" onClick={() => setCurrent({ ...EMPTY })}> Neuer Beitrag</button>
<div className="search"><span></span><input placeholder="Suchen…" value={query} onChange={(e) => setQuery(e.target.value)} /></div>
{['beitrag', 'seite', 'rubrik'].map((kind) => groups[kind].length > 0 && (
<div className="group" key={kind}>
<div className="group-title">{KIND_LABEL[kind]} <span>{groups[kind].length}</span></div>
<ul className="list">
{groups[kind].map((e) => (
<li key={e.path} className={current?.path === e.path ? 'active' : ''} onClick={() => open(e)}>
<span className="dot" style={{ background: e.color ? hexOf(e.color) : 'var(--line)' }} />
<span className="t">
<span className="t-title">{e.title}</span>
<span className="t-meta">{[e.section, e.date].filter(Boolean).join(' · ')}</span>
</span>
{e.draft && <span className="draft-tag">Entwurf</span>}
</li>
))}
</ul>
</div>
))}
</aside>
<main>
{current
? <Editor key={current.path || 'new'} initial={current}
onSaved={(loaded) => { setCurrent(loaded); refresh(); }} onMsg={setMsg} />
: <div className="empty"><p>Wähle links einen Eintrag oder leg einen neuen Beitrag an.</p></div>}
</main>
</>
)}
</div>
{msg && <div className={`toast ${msg.type}`} onClick={() => setMsg(null)}>{msg.text}</div>}
@@ -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 (
<div className="editor">
<div className="editor" ref={editorRef}>
<div className="editor-main">
<div className="editor-head">
<div className="crumb">{f.isNew ? 'Neuer Eintrag' : f.path}</div>
<span className="spacer" />
{f.draft
? <span className="status draft">Entwurf</span>
: <span className="status live">Veröffentlicht</span>}
{f.draft ? <span className="status draft">Entwurf</span> : <span className="status live">Veröffentlicht</span>}
<button className="toggle" onClick={() => setShowPreview((v) => !v)} title="Vorschau ein/aus">
{showPreview ? 'Vorschau ' : 'Vorschau ⤢'}
</button>
@@ -207,71 +223,58 @@ function Editor({ initial, onSaved, onMsg }) {
</div>
<div className="fields">
<div className="path-row">
{f.isNew ? (
<>
<label className="sm">Typ
<select value={f.type} onChange={set('type')}>
<option value="beitrag">Beitrag</option>
<option value="seite">Seite</option>
</select>
{f.isNew && (
<div className="row">
<label className="sm">Typ
<select value={f.type} onChange={set('type')}>
<option value="beitrag">Beitrag</option>
<option value="seite">Seite</option>
</select>
</label>
{f.type === 'beitrag' && (
<label className="sm">Rubrik
<select value={f.section} onChange={set('section')}>{SECTIONS.map((s) => <option key={s}>{s}</option>)}</select>
</label>
{f.type === 'beitrag' && (
<label className="sm">Rubrik
<select value={f.section} onChange={set('section')}>
{SECTIONS.map((s) => <option key={s}>{s}</option>)}
</select>
</label>
)}
<label>Slug<input value={f.slug} onChange={set('slug')} placeholder="z.B. neuer-beitrag" /></label>
</>
) : null}
</div>
)}
<label>Slug<input value={f.slug} onChange={set('slug')} placeholder="z.B. neuer-beitrag" /></label>
</div>
)}
<label className="big">Titel<input value={f.title} onChange={set('title')} placeholder="Titel des Beitrags" /></label>
<div className="row">
<div className="meta">
<label className="sm">Datum<input type="date" value={f.date} onChange={set('date')} /></label>
<label className="xs">Reihenfolge<input type="number" value={f.weight} onChange={set('weight')} placeholder="weight" /></label>
<label>Farbe
<label className="sm">Farbe
<div className="colorpick">
<span className="swatch" style={{ background: hexOf(f.color) }} />
<select value={f.color} onChange={set('color')}>
{COLORS.map(([v, label]) => <option key={v} value={v}>{label}</option>)}
</select>
<select value={f.color} onChange={set('color')}>{COLORS.map(([v, label]) => <option key={v} value={v}>{label}</option>)}</select>
</div>
</label>
</div>
<div className="row">
<label className="sm">Layout
<select value={f.layout} onChange={set('layout')}>
{LAYOUTS.map((l) => <option key={l} value={l}>{l || '(automatisch)'}</option>)}
</select>
<select value={f.layout} onChange={set('layout')}>{LAYOUTS.map((l) => <option key={l} value={l}>{l || '(automatisch)'}</option>)}</select>
</label>
<label>Tags<input value={f.tags} onChange={set('tags')} placeholder="komma, getrennt" /></label>
<label className="check"><input type="checkbox" checked={f.toc} onChange={set('toc')} /> Inhaltsverzeichnis</label>
<label className="check"><input type="checkbox" checked={f.toc} onChange={set('toc')} /> Inhaltsverz.</label>
<label className="check"><input type="checkbox" checked={f.draft} onChange={set('draft')} /> Entwurf</label>
</div>
<label>Kurztext (summary)<input value={f.summary} onChange={set('summary')} /></label>
<div className="row">
<label>Cover-Bild<input value={f.cover_image} onChange={set('cover_image')} placeholder="/images/…jpg" /></label>
<label>Externer Link<input value={f.external} onChange={set('external')} placeholder="https://…" /></label>
</div>
<MarkdownEditor
value={f.body}
onChange={(body) => setF((p) => ({ ...p, body }))}
onUpload={async (file) => (await api.upload(file)).url}
onMsg={onMsg}
/>
<div className="rich">
<RichEditor value={f.body} onChange={(body) => setF((p) => ({ ...p, body }))}
onUpload={async (file) => (await api.upload(file)).url} />
</div>
</div>
</div>
{showPreview && <div className="splitter" onMouseDown={startDrag} />}
{showPreview && (
<div className="preview">
<div className="preview" style={{ width: pw + '%' }}>
{previewUrl
? <iframe title="Vorschau" src={previewUrl} />
: <div className="empty small"><p>Auf Vorschau klicken die Seite erscheint hier in deinem echten Theme.</p></div>}
@@ -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 <div ref={el} className="rich-host" />;
}
// ── 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 <div className="empty"></div>;
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 }) => (
<button type="button" className="tb" title={title} onMouseDown={(e) => { e.preventDefault(); on(); }}>{children}</button>
);
return (
<div className="md">
<div className="toolbar">
<B title="Fett" on={() => wrap('**', '**', 'fett')}><b>B</b></B>
<B title="Kursiv" on={() => wrap('*', '*', 'kursiv')}><i>I</i></B>
<B title="Unterstrichen" on={() => wrap('<u>', '</u>', 'unterstrichen')}><u>U</u></B>
<span className="sep" />
<B title="Überschrift" on={() => linePrefix('## ')}>H2</B>
<B title="Zwischentitel" on={() => linePrefix('### ')}>H3</B>
<span className="sep" />
<B title="Link" on={() => wrap('[', '](https://)', 'Linktext')}>🔗</B>
<B title="Bild hochladen" on={() => fileIn.current?.click()}>🖼</B>
<span className="sep" />
<B title="Aufzählung" on={() => linePrefix('- ')}> Liste</B>
<B title="Nummeriert" on={() => linePrefix('1. ')}>1.</B>
<B title="Zitat" on={() => linePrefix('> ')}></B>
<B title="Code" on={() => wrap('`', '`', 'code')}>{'</>'}</B>
<input ref={fileIn} type="file" accept="image/*" hidden onChange={pickImage} />
<div className="profile">
<div className="profile-card">
<h2>Profil</h2>
<div className="avatar-row">
<div className="avatar" style={{ backgroundImage: p.avatar ? `url(${p.avatar})` : 'none' }}>{!p.avatar && '🙂'}</div>
<div>
<button onClick={() => fileIn.current?.click()} disabled={busy}>Profilbild wählen</button>
<input ref={fileIn} type="file" accept="image/*" hidden onChange={pickAvatar} />
<p className="muted who-mail">{p.email}</p>
</div>
</div>
<label>Name<input value={p.name} onChange={set('name')} placeholder="Dein Name" /></label>
<label>Über mich<textarea value={p.bio} onChange={set('bio')} rows={5} placeholder="Kurzer Text über dich…" /></label>
<div className="actions"><button className="primary" onClick={save} disabled={busy}>Speichern</button></div>
</div>
<textarea ref={ta} value={value} spellCheck={false}
onChange={(e) => onChange(e.target.value)} placeholder="Hier schreiben… (Markdown)" />
</div>
);
}
+2
View File
@@ -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) }),
};
+44 -43
View File
@@ -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); }
+2
View File
@@ -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/'));
+32
View File
@@ -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;