cms: dateibasiert + Editor im Decap/Sveltia-Look

- CMS liest/schreibt jetzt die echten content/**/*.md (gray-matter) statt DB:
  alle bestehenden Beiträge, Seiten und Rubriken erscheinen und sind editierbar.
  Supabase nur noch für Login.
- Admin neu: Collections-Sidebar (Beiträge/Seiten/Rubriken), an OPENBUREAU-Theme
  angeglichen (Newsreader-Serif, Creme, Terracotta, dunkle Topbar).
- Alle Frontmatter-Felder inkl. Farb-Dropdown mit Farbpunkten (Palette aus
  custom.css), Layout, Tags, summary, cover_image, external, toc, draft.
- Markdown-Toolbar: Fett/Kursiv/Unterstrichen/H2/H3/Link/Bild-Upload/Liste/Zitat/Code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 11:51:49 +02:00
parent e7d820b83c
commit e2d986356c
12 changed files with 526 additions and 377 deletions
+18 -16
View File
@@ -28,13 +28,16 @@ docker compose
nicht — Bild-Uploads gehen auf Platte nach `static/images/`). Nachrüstbar durch
Kopieren der Service-Blöcke aus RAPPORT-SERVER.
## Quelle der Wahrheit (DB-backed)
## Quelle der Wahrheit: die `.md`-Dateien
Die `posts`-Tabelle in Supabase ist kanonisch. `content/*.md` ist ein
**generiertes Artefakt** — nicht von Hand editieren, wird beim Publish
überschrieben. Drafts liegen als `draft: true` in `content/` und werden vom
Live-Build automatisch ausgelassen; nur der Preview-Build (`--buildDrafts`)
zeigt sie.
Dateibasiert. Die echten `content/**/*.md` sind kanonisch — das CMS liest und
schreibt sie direkt (Frontmatter via gray-matter). Damit erscheinen **alle**
bestehenden Inhalte im Editor: Beiträge (`library/<rubrik>/…`), Seiten
(`manifest.md`, `colophon.md`) und Rubriken (`_index.md`).
Supabase wird **nur noch für den Login** (GoTrue) gebraucht — keine Posts in der
DB. Drafts liegen als `draft: true` in der Datei; der Live-Build lässt sie aus,
der Preview-Build (`--buildDrafts`) zeigt sie.
## Setup
@@ -78,16 +81,15 @@ Dann: Admin `…:8080/admin/` · Live `…:8080/` · Preview `…:8080/_preview/
Alle `/api/*` (ausser `/health`) verlangen `Authorization: Bearer <supabase-token>`.
| Methode | Pfad | Zweck |
|---------|---------------------|----------------------------------------|
| GET | `/api/health` | Healthcheck (offen) |
| GET | `/api/posts` | Alle Posts listen |
| GET | `/api/posts/:id` | Einen Post |
| POST | `/api/posts` | Post anlegen (Draft) |
| PUT | `/api/posts/:id` | Post aktualisieren |
| POST | `/api/preview/:id` | Draft als `draft:true` schreiben + Preview-Build |
| POST | `/api/publish/:id` | Live schreiben + Public-Build + (opt.) git commit |
| POST | `/api/upload` | Bild → `static/images/`, liefert `/images/<name>` |
| Methode | Pfad | Zweck |
|---------|-------------------------------|----------------------------------------|
| GET | `/api/health` | Healthcheck (offen) |
| GET | `/api/content` | Alle Einträge listen (Beiträge/Seiten/Rubriken) |
| GET | `/api/content/entry?path=…` | Einen Eintrag lesen (Frontmatter + Body) |
| PUT | `/api/content/entry` | Eintrag anlegen/speichern (`{path, frontmatter, body}`) |
| POST | `/api/preview` | Preview-Build (`--buildDrafts`), liefert `/_preview/…` |
| POST | `/api/publish` | Public-Build → live + (opt.) git commit |
| POST | `/api/upload` | Bild → `static/images/`, liefert `/images/<name>` |
## Stand
+241 -146
View File
@@ -1,23 +1,32 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { supabase } from './supabase.js';
import { api } from './api.js';
// OPENBUREAU-Palette (Hex aus assets/css/custom.css) — für Dropdown + Punkte.
const COLORS = [
['', 'keine', 'transparent'],
['ajisai', 'Ajisai · Hortensie', '#A39EC4'],
['sakura', 'Sakura · Kirschblüte', '#C49EC4'],
['suna', 'Suna · Sand', '#C4C19E'],
['ichigo', 'Ichigo · Erdbeere', '#C49EA0'],
['yuyake', 'Yuyake · Sonnenuntergang', '#CEB188'],
['sora', 'Sora · Himmel', '#9EC3C4'],
['kusa', 'Kusa · Gras', '#9EC49F'],
['kori', 'Kori · Eis', '#A5B4CB'],
['amagumo', 'Amagumo · Regenwolke', '#4C4C4C'],
['yuki', 'Yuki · Schnee', '#F0F0F0'],
];
const hexOf = (name) => (COLORS.find((c) => c[0] === name) || [])[2] || 'transparent';
const LAYOUTS = ['', 'text', 'image', 'icon'];
const SECTIONS = ['buerofuehrung', 'software', 'theorie'];
const LAYOUTS = ['', 'text', 'image'];
const KIND_LABEL = { beitrag: 'Beiträge', seite: 'Seiten', rubrik: 'Rubriken' };
const EMPTY = {
section: 'software',
slug: '',
title: '',
date: new Date().toISOString().slice(0, 10),
weight: '',
tags: '',
summary: '',
cover_image: '',
layout: 'text',
external: '',
color: '',
body: '',
isNew: true, path: '', type: 'beitrag', section: 'software', slug: '',
title: '', date: new Date().toISOString().slice(0, 10), weight: '',
color: '', layout: 'text', tags: '', summary: '', description: '',
cover_image: '', external: '', toc: false, draft: true, body: '',
};
export default function App() {
@@ -25,15 +34,12 @@ export default function App() {
const [loading, setLoading] = useState(true);
useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
setSession(data.session);
setLoading(false);
});
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"></div>;
if (loading) return <div className="center muted"></div>;
if (!session) return <Login />;
return <Dashboard email={session.user.email} />;
}
@@ -42,23 +48,19 @@ function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState(null);
async function submit(e) {
e.preventDefault();
setErr(null);
e.preventDefault(); setErr(null);
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) setErr(error.message);
}
return (
<div className="center">
<form className="card login" onSubmit={submit}>
<h1>OPENBUREAU CMS</h1>
<input type="email" placeholder="E-Mail" value={email}
onChange={(e) => setEmail(e.target.value)} autoFocus />
<input type="password" placeholder="Passwort" value={password}
onChange={(e) => setPassword(e.target.value)} />
<button type="submit">Einloggen</button>
<form className="login" onSubmit={submit}>
<div className="login-brand">OPENBUREAU</div>
<div className="login-sub">Redaktion</div>
<input type="email" placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} autoFocus />
<input type="password" placeholder="Passwort" value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit">Anmelden</button>
{err && <p className="err">{err}</p>}
</form>
</div>
@@ -66,49 +68,62 @@ function Login() {
}
function Dashboard({ email }) {
const [posts, setPosts] = useState([]);
const [current, setCurrent] = useState(null); // post-Objekt oder null
const [entries, setEntries] = useState([]);
const [current, setCurrent] = useState(null);
const [msg, setMsg] = useState(null);
async function refresh() {
try {
setPosts(await api.listPosts());
} catch (e) {
setMsg({ type: 'err', text: e.message });
}
try { setEntries(await api.list()); }
catch (e) { setMsg({ type: 'err', text: e.message }); }
}
useEffect(() => { refresh(); }, []);
useEffect(() => { if (!msg) return; const t = setTimeout(() => setMsg(null), 4000); return () => clearTimeout(t); }, [msg]);
async function open(entry) {
try {
const e = await api.read(entry.path);
setCurrent(fromRead(e));
} catch (err) { setMsg({ type: 'err', text: err.message }); }
}
// Nach Typ gruppieren.
const groups = { beitrag: [], seite: [], rubrik: [] };
for (const e of entries) (groups[e.kind] || groups.seite).push(e);
return (
<div className="app">
<header>
<strong>OPENBUREAU CMS</strong>
<header className="topbar">
<span className="logo">OPENBUREAU</span>
<span className="logo-sub">Redaktion</span>
<span className="spacer" />
<span className="muted">{email}</span>
<button onClick={() => supabase.auth.signOut()}>Logout</button>
<button className="ghost" onClick={() => supabase.auth.signOut()}>Abmelden</button>
</header>
<div className="body">
<aside>
<button className="new" onClick={() => setCurrent({ ...EMPTY })}>+ Neuer Post</button>
<ul className="list">
{posts.map((p) => (
<li key={p.id} className={current?.id === p.id ? 'active' : ''}
onClick={() => setCurrent(fromRow(p))}>
<span className={`dot ${p.status}`} />
<span className="t">{p.title || '(ohne Titel)'}</span>
<span className="s">{p.section}</span>
</li>
))}
</ul>
<button className="new" onClick={() => setCurrent({ ...EMPTY })}> Neuer Beitrag</button>
{['beitrag', 'seite', 'rubrik'].map((kind) => groups[kind].length > 0 && (
<div className="group" key={kind}>
<div className="group-title">{KIND_LABEL[kind]}</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">{e.title}{e.draft && <em className="draft-tag">Entwurf</em>}</span>
{e.section && <span className="s">{e.section}</span>}
</li>
))}
</ul>
</div>
))}
</aside>
<main>
{current
? <Editor key={current.id || 'new'} initial={current}
onSaved={(row) => { setCurrent(fromRow(row)); refresh(); }}
onMsg={setMsg} />
: <p className="muted pad">Wähle links einen Post oder leg einen neuen an.</p>}
? <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>
@@ -118,111 +133,123 @@ function Dashboard({ email }) {
}
function Editor({ initial, onSaved, onMsg }) {
const [post, setPost] = useState(initial);
const [f, setF] = useState(initial);
const [previewUrl, setPreviewUrl] = useState(null);
const [busy, setBusy] = useState(false);
const set = (k) => (e) => setPost({ ...post, [k]: e.target.value });
const set = (k) => (e) => setF({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value });
async function uploadCover(e) {
const file = e.target.files?.[0];
if (!file) return;
setBusy(true);
try {
const res = await api.upload(file);
setPost((p) => ({ ...p, cover_image: res.url }));
onMsg({ type: 'ok', text: `Hochgeladen: ${res.url}` });
} catch (err) {
onMsg({ type: 'err', text: err.message });
} finally {
setBusy(false);
}
function currentPath() {
if (!f.isNew) return f.path;
const slug = (f.slug || '').trim();
if (!slug) return '';
return f.type === 'beitrag' ? `library/${f.section}/${slug}.md` : `${slug}.md`;
}
async function save() {
const path = currentPath();
if (!path) { onMsg({ type: 'err', text: 'Bitte einen Slug angeben.' }); return null; }
if (!f.title.trim()) { onMsg({ type: 'err', text: 'Titel fehlt.' }); return null; }
setBusy(true);
try {
const payload = toRow(post);
const row = post.id
? await api.updatePost(post.id, payload)
: await api.createPost(payload);
onSaved(row);
await api.save(path, buildFrontmatter(f), f.body);
const loaded = fromRead(await api.read(path));
onSaved(loaded);
setF(loaded);
onMsg({ type: 'ok', text: 'Gespeichert.' });
return row;
} catch (e) {
onMsg({ type: 'err', text: e.message });
} finally {
setBusy(false);
}
return path;
} catch (e) { onMsg({ type: 'err', text: e.message }); return null; }
finally { setBusy(false); }
}
async function preview() {
const row = await save();
if (!row) return;
const path = await save();
if (!path) return;
setBusy(true);
try {
const res = await api.preview(row.id);
// Cache-Buster, damit der frische Build geladen wird.
const res = await api.preview(path);
setPreviewUrl(`${res.url}?t=${Date.now()}`);
} catch (e) {
onMsg({ type: 'err', text: e.message });
} finally {
setBusy(false);
}
} catch (e) { onMsg({ type: 'err', text: e.message }); }
finally { setBusy(false); }
}
async function publish() {
const row = await save();
if (!row) return;
if (!confirm('Diesen Post live publizieren?')) return;
const path = await save();
if (!path) return;
if (!confirm('Diesen Eintrag live publizieren?')) return;
setBusy(true);
try {
const res = await api.publish(row.id);
const res = await api.publish(path);
onMsg({ type: 'ok', text: `Live: ${res.url}` });
} catch (e) {
onMsg({ type: 'err', text: e.message });
} finally {
setBusy(false);
}
} catch (e) { onMsg({ type: 'err', text: e.message }); }
finally { setBusy(false); }
}
return (
<div className="editor">
<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>
</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>
</>
) : (
<div className="pathlabel">{f.path}</div>
)}
</div>
<label className="big">Titel<input value={f.title} onChange={set('title')} /></label>
<div className="row">
<label>Titel<input value={post.title} onChange={set('title')} /></label>
<label className="sm">Section
<select value={post.section} onChange={set('section')}>
{SECTIONS.map((s) => <option key={s}>{s}</option>)}
</select>
<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
<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>
</div>
</label>
</div>
<div className="row">
<label>Slug<input value={post.slug} onChange={set('slug')} placeholder="a-z0-9-" /></label>
<label className="sm">Datum<input type="date" value={post.date} onChange={set('date')} /></label>
<label className="xs">Weight<input type="number" value={post.weight} onChange={set('weight')} /></label>
</div>
<div className="row">
<label className="sm">Layout
<select value={post.layout} onChange={set('layout')}>
{LAYOUTS.map((l) => <option key={l} value={l}>{l || '(default)'}</option>)}
<select value={f.layout} onChange={set('layout')}>
{LAYOUTS.map((l) => <option key={l} value={l}>{l || '(automatisch)'}</option>)}
</select>
</label>
<label className="sm">Color<input value={post.color} onChange={set('color')} placeholder="kusa / yuyake" /></label>
<label>Tags<input value={post.tags} onChange={set('tags')} placeholder="komma, getrennt" /></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.draft} onChange={set('draft')} /> Entwurf</label>
</div>
<label>Summary<input value={post.summary} onChange={set('summary')} /></label>
<label>Kurztext (summary)<input value={f.summary} onChange={set('summary')} /></label>
<div className="row">
<label>Cover-Bild
<input value={post.cover_image} onChange={set('cover_image')} placeholder="/images/...jpg" />
</label>
<label className="sm">Hochladen
<input type="file" accept="image/*" onChange={uploadCover} disabled={busy} />
</label>
<label>Externer Link<input value={post.external} onChange={set('external')} placeholder="https://…" /></label>
<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>
<label className="grow">Inhalt (Markdown)
<textarea value={post.body} onChange={set('body')} spellCheck={false} />
</label>
<MarkdownEditor
value={f.body}
onChange={(body) => setF((p) => ({ ...p, body }))}
onUpload={async (file) => (await api.upload(file)).url}
onMsg={onMsg}
/>
<div className="actions">
<button onClick={save} disabled={busy}>Speichern</button>
<button onClick={preview} disabled={busy}>Vorschau</button>
@@ -233,34 +260,102 @@ function Editor({ initial, onSaved, onMsg }) {
<div className="preview">
{previewUrl
? <iframe title="Vorschau" src={previewUrl} />
: <p className="muted pad">Vorschau erscheint hier nach Klick auf Vorschau".</p>}
: <div className="empty small"><p>Vorschau erscheint hier nach Vorschau.</p></div>}
</div>
</div>
);
}
// --- DB-Zeile <-> Formular ---
function fromRow(row) {
// ── Markdown-Editor mit Formatierungs-Toolbar ──────────────────────────────
function MarkdownEditor({ value, onChange, onUpload, onMsg }) {
const ta = useRef(null);
const fileIn = useRef(null);
function restore(start, end) {
requestAnimationFrame(() => {
const el = ta.current; if (!el) return;
el.focus(); el.selectionStart = start; el.selectionEnd = end;
});
}
function wrap(pre, post = pre, placeholder = '') {
const el = ta.current; const s = el.selectionStart, e = el.selectionEnd;
const sel = value.slice(s, e) || placeholder;
const next = value.slice(0, s) + pre + sel + post + value.slice(e);
onChange(next); 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;
const next = value.slice(0, ls) + prefix + value.slice(ls);
onChange(next); restore(s + prefix.length, s + prefix.length);
}
function insert(text) {
const el = ta.current; const s = el.selectionStart, e = el.selectionEnd;
const next = value.slice(0, s) + text + value.slice(e);
onChange(next); restore(s + text.length, s + text.length);
}
async function pickImage(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 }); }
}
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>
<textarea ref={ta} value={value} spellCheck={false}
onChange={(e) => onChange(e.target.value)} placeholder="Hier schreiben… (Markdown)" />
</div>
);
}
// ── Mapping DB-Lesart → Formular ───────────────────────────────────────────
function fromRead(r) {
const fm = r.frontmatter || {};
return {
...EMPTY, ...row,
weight: row.weight ?? '',
tags: Array.isArray(row.tags) ? row.tags.join(', ') : '',
date: row.date ? String(row.date).slice(0, 10) : EMPTY.date,
isNew: false, path: r.path, type: 'beitrag', section: '', slug: '',
title: fm.title || '', date: fm.date ? String(fm.date).slice(0, 10) : '',
weight: fm.weight ?? '', color: fm.color || '', layout: fm.layout || '',
tags: Array.isArray(fm.tags) ? fm.tags.join(', ') : '',
summary: fm.summary || '', description: fm.description || '',
cover_image: fm.cover_image || '', external: fm.external || '',
toc: !!fm.toc, draft: !!fm.draft, body: r.body || '',
};
}
function toRow(post) {
return {
section: post.section,
slug: post.slug,
title: post.title,
date: post.date,
weight: post.weight === '' ? null : Number(post.weight),
tags: post.tags ? post.tags.split(',').map((t) => t.trim()).filter(Boolean) : [],
summary: post.summary || null,
cover_image: post.cover_image || null,
layout: post.layout || null,
external: post.external || null,
color: post.color || null,
body: post.body || '',
};
function buildFrontmatter(f) {
const fm = { title: f.title };
if (f.date) fm.date = f.date;
if (f.weight !== '' && f.weight != null) fm.weight = Number(f.weight);
const tags = f.tags ? f.tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
if (tags.length) fm.tags = tags;
if (f.summary) fm.summary = f.summary;
if (f.description) fm.description = f.description;
if (f.cover_image) fm.cover_image = f.cover_image;
if (f.layout) fm.layout = f.layout;
if (f.external) fm.external = f.external;
if (f.color) fm.color = f.color;
if (f.toc) fm.toc = true;
if (f.draft) fm.draft = true;
return fm;
}
+7 -6
View File
@@ -17,7 +17,7 @@ async function call(path, options = {}) {
return json;
}
// Datei-Upload: kein JSON, der Browser setzt den multipart-Header selbst.
// Datei-Upload (multipart): Browser setzt den Header selbst.
async function uploadFile(file) {
const { data } = await supabase.auth.getSession();
const token = data?.session?.access_token;
@@ -34,10 +34,11 @@ async function uploadFile(file) {
}
export const api = {
listPosts: () => call('/posts'),
createPost: (post) => call('/posts', { method: 'POST', body: JSON.stringify(post) }),
updatePost: (id, post) => call(`/posts/${id}`, { method: 'PUT', body: JSON.stringify(post) }),
preview: (id) => call(`/preview/${id}`, { method: 'POST' }),
publish: (id) => call(`/publish/${id}`, { method: 'POST' }),
list: () => call('/content'),
read: (path) => call(`/content/entry?path=${encodeURIComponent(path)}`),
save: (path, frontmatter, body) =>
call('/content/entry', { method: 'PUT', body: JSON.stringify({ path, frontmatter, body }) }),
preview: (path) => call('/preview', { method: 'POST', body: JSON.stringify({ path }) }),
publish: (path) => call('/publish', { method: 'POST', body: JSON.stringify({ path }) }),
upload: uploadFile,
};
+112 -48
View File
@@ -1,70 +1,134 @@
@import url('https://fonts.bunny.net/css?family=newsreader:400,500,600,700|inter:400,500,600|space-grotesk:500,700|ibm-plex-mono:400,500');
:root {
--bg: #14110e;
--panel: #1d1a16;
--line: #322c25;
--text: #ece6dd;
--muted: #8a8078;
--accent: #c8543a;
--ok: #5a8a4a;
--serif: 'Newsreader', Georgia, serif;
--sans: 'Inter', system-ui, -apple-system, sans-serif;
--display: 'Space Grotesk', 'Inter', sans-serif;
--mono: 'IBM Plex Mono', ui-monospace, monospace;
--bg: hsl(35 14% 96%);
--panel: #fffdf9;
--panel-2: hsl(35 14% 93%);
--line: hsl(35 14% 86%);
--text: hsl(25 18% 12%);
--muted: hsl(25 8% 42%);
--accent: #b54a2c;
--accent-soft: #d97a5a;
--dark: #191919;
--dark-text: #f0f0f0;
--dark-muted: #a9a9a9;
--ok: #5d7d4b;
--radius: 11px;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body {
margin: 0;
font: 15px/1.5 -apple-system, system-ui, sans-serif;
background: var(--bg);
font-family: var(--sans);
font-size: 14.5px;
color: var(--text);
background: var(--bg);
}
.center { display: grid; place-items: center; min-height: 100vh; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 28px; }
.login { display: flex; flex-direction: column; gap: 12px; width: 300px; }
.login h1 { font-size: 18px; margin: 0 0 8px; letter-spacing: .08em; }
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%; }
input, select, textarea, button {
font: inherit; color: var(--text);
background: #110e0b; border: 1px solid var(--line);
border-radius: 6px; padding: 8px 10px;
/* ── 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: 0 12px 40px -20px rgba(0,0,0,.3);
}
input:focus, select:focus, textarea:focus { outline: 1px solid var(--accent); }
button { background: #2a241e; cursor: pointer; }
button:hover { border-color: var(--accent); }
.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 ── */
input, select, textarea {
background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
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: 9px 16px; cursor: pointer; font-weight: 500; transition: .12s;
}
button:hover { border-color: var(--accent-soft); }
button:disabled { opacity: .5; cursor: default; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.err { color: var(--accent); margin: 0; }
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 { display: flex; flex-direction: column; height: 100vh; }
header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--line); }
.spacer { flex: 1; }
.muted { color: var(--muted); }
.pad { padding: 24px; }
/* ── App-Rahmen ── */
.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 .muted { color: var(--dark-muted); font-size: 13px; }
.body { display: flex; flex: 1; min-height: 0; }
aside { width: 260px; border-right: 1px solid var(--line); padding: 12px; overflow: auto; }
.new { width: 100%; margin-bottom: 12px; }
/* ── Sidebar ── */
aside { width: 280px; flex: none; border-right: 1px solid var(--line); background: var(--panel-2); padding: 14px; overflow: auto; }
.new { width: 100%; margin-bottom: 16px; background: var(--accent); border-color: var(--accent); color: #fff; }
.new:hover { background: #a23f23; }
.group { margin-bottom: 18px; }
.group-title { font-family: var(--display); font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin: 0 6px 7px; }
.list { list-style: none; margin: 0; padding: 0; }
.list li { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: 6px; cursor: pointer; }
.list li:hover { background: #221d18; }
.list li.active { background: #2a241e; }
.list .t { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.list .s { color: var(--muted); font-size: 12px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
.dot.published { background: var(--ok); }
.dot.draft { background: #b89030; }
.list li { display: flex; align-items: center; gap: 9px; padding: 8px 9px; border-radius: 8px; cursor: pointer; }
.list li:hover { background: var(--panel); }
.list li.active { background: var(--panel); box-shadow: inset 2px 0 0 var(--accent); }
.list .dot { width: 9px; height: 9px; border-radius: 50%; flex: none; border: 1px solid rgba(0,0,0,.12); }
.list .t { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--serif); font-size: 15px; }
.list .s { font-size: 11px; color: var(--muted); }
.draft-tag { font-style: normal; font-size: 10px; color: #b8902f; margin-left: 6px; font-family: var(--sans); }
main { flex: 1; min-width: 0; }
.empty { display: grid; place-items: center; height: 100%; color: var(--muted); font-family: var(--serif); font-style: italic; padding: 24px; text-align: center; }
.empty.small { font-size: 14px; }
/* ── Editor ── */
.editor { display: flex; height: 100%; }
.fields { width: 50%; padding: 16px; overflow: auto; display: flex; flex-direction: column; gap: 12px; }
.row { display: flex; gap: 10px; }
label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--muted); flex: 1; }
label.sm { flex: 0 0 140px; }
label.xs { flex: 0 0 90px; }
label.grow { flex: 1; }
label input, label select, label textarea { color: var(--text); }
textarea { flex: 1; min-height: 240px; resize: vertical; font-family: ui-monospace, monospace; }
.actions { display: flex; gap: 8px; }
.fields { width: 52%; padding: 22px 24px; overflow: auto; display: flex; flex-direction: column; gap: 14px; }
.row { display: flex; gap: 12px; 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; }
label.check input { width: auto; }
label.big input { font-family: var(--serif); font-size: 22px; font-weight: 600; padding: 11px 13px; }
.preview { width: 50%; border-left: 1px solid var(--line); }
.preview iframe { width: 100%; height: 100%; border: 0; background: #fff; }
.path-row { display: flex; gap: 12px; align-items: flex-end; }
.pathlabel { font-family: var(--mono); font-size: 12px; color: var(--muted); background: var(--panel-2); border: 1px solid var(--line); border-radius: 7px; padding: 6px 10px; }
.toast { position: fixed; bottom: 18px; right: 18px; padding: 10px 16px; border-radius: 8px; cursor: pointer; }
.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); }
.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; }
.actions { display: flex; gap: 9px; padding-top: 4px; }
/* ── Vorschau-Pane ── */
.preview { width: 48%; border-left: 1px solid var(--line); background: #fff; }
.preview iframe { width: 100%; height: 100%; border: 0; }
/* ── 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.ok { background: var(--ok); }
.toast.err { background: var(--accent); }
-37
View File
@@ -1,37 +0,0 @@
import { mkdir, writeFile, rm } from 'node:fs/promises';
import path from 'node:path';
import { rowToMarkdown } from './render.js';
const SITE_DIR = process.env.SITE_DIR || '/site';
// Erlaubt nur sichere, einfache Segmente — verhindert Path-Traversal über
// section/slug aus der DB.
function safeSegment(value, label) {
if (!value || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(String(value))) {
throw new Error(`Ungültiger ${label}: ${JSON.stringify(value)}`);
}
return value;
}
// Posts leben unter content/library/<section>/<slug>.md → URL /library/<section>/<slug>/.
const CONTENT_BASE = 'library';
export function postPath(post) {
const section = safeSegment(post.section, 'section');
const slug = safeSegment(post.slug, 'slug');
return path.join(SITE_DIR, 'content', CONTENT_BASE, section, `${slug}.md`);
}
// Schreibt die generierte MD nach content/<section>/<slug>.md.
// draft:true -> Live-Build (ohne --buildDrafts) lässt den Post aus.
// draft:false -> Post ist live.
export async function writePostFile(post, { draft = false } = {}) {
const file = postPath(post);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, rowToMarkdown(post, { draft }), 'utf8');
return file;
}
export async function removePostFile(post) {
await rm(postPath(post), { force: true });
}
+103
View File
@@ -0,0 +1,103 @@
import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
const SITE_DIR = process.env.SITE_DIR || '/site';
const CONTENT = path.join(SITE_DIR, 'content');
// Pfad-Sicherheit: relativer Pfad innerhalb content/, nur .md.
export function safeRel(rel) {
if (!rel || typeof rel !== 'string') throw new Error('Pfad fehlt');
const norm = path.normalize(rel).split(path.sep).join('/');
if (norm.startsWith('..') || norm.startsWith('/') || norm.includes('../')) {
throw new Error('Ungültiger Pfad');
}
if (!norm.endsWith('.md')) throw new Error('Nur .md erlaubt');
return norm;
}
async function walk(dir) {
const out = [];
for (const e of await readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, e.name);
if (e.isDirectory()) out.push(...(await walk(full)));
else if (e.name.endsWith('.md')) out.push(full);
}
return out;
}
// Beitrag (library/<section>/<slug>.md) | Rubrik (_index.md) | Seite (sonst).
function classify(rel) {
const base = path.basename(rel);
const parts = rel.split('/');
if (base === '_index.md') {
const section = parts.length >= 2 ? parts[parts.length - 2] : 'home';
return { kind: 'rubrik', section };
}
if (parts[0] === 'library' && parts.length === 3) {
return { kind: 'beitrag', section: parts[1] };
}
return { kind: 'seite', section: null };
}
// Hugo-URL aus dem relativen Pfad.
export function urlFor(rel) {
let p = rel.replace(/\.md$/, '');
if (p === '_index') return '/';
p = p.replace(/\/_index$/, '');
return '/' + p + '/';
}
export async function listEntries() {
const files = await walk(CONTENT);
const items = [];
for (const full of files) {
const rel = path.relative(CONTENT, full).split(path.sep).join('/');
let data = {};
try { data = matter(await readFile(full, 'utf8')).data || {}; } catch {}
items.push({
path: rel,
title: data.title || rel,
...classify(rel),
color: data.color || null,
layout: data.layout || null,
draft: !!data.draft,
date: data.date ? String(data.date).slice(0, 10) : null,
url: urlFor(rel),
});
}
// Beiträge zuerst, dann Seiten, dann Rubriken; je nach Datum/Titel.
const order = { beitrag: 0, seite: 1, rubrik: 2 };
items.sort((a, b) =>
(order[a.kind] - order[b.kind]) ||
(b.date || '').localeCompare(a.date || '') ||
a.title.localeCompare(b.title));
return items;
}
export async function readEntry(rel) {
rel = safeRel(rel);
const { data, content } = matter(await readFile(path.join(CONTENT, rel), 'utf8'));
return { path: rel, url: urlFor(rel), frontmatter: data || {}, body: content || '' };
}
export async function entryExists(rel) {
try { await stat(path.join(CONTENT, safeRel(rel))); return true; }
catch { return false; }
}
export async function writeEntry(rel, frontmatter = {}, body = '') {
rel = safeRel(rel);
const full = path.join(CONTENT, rel);
await mkdir(path.dirname(full), { recursive: true });
// Leere Werte rauswerfen, damit das Frontmatter sauber bleibt.
const fm = {};
for (const [k, v] of Object.entries(frontmatter)) {
if (v === '' || v === null || v === undefined) continue;
if (Array.isArray(v) && v.length === 0) continue;
fm[k] = v;
}
await writeFile(full, matter.stringify(body || '', fm), 'utf8');
return rel;
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { Hono } from 'hono';
import posts from './routes/posts.js';
import content from './routes/content.js';
import preview from './routes/preview.js';
import publish from './routes/publish.js';
import upload from './routes/upload.js';
@@ -18,7 +18,7 @@ const app = new Hono();
app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' }));
// Alles unter /api/* (ausser /health oben) braucht ein gültiges Supabase-Token.
app.use('/api/*', requireAuth);
app.route('/api/posts', posts);
app.route('/api/content', content);
app.route('/api/preview', preview);
app.route('/api/publish', publish);
app.route('/api/upload', upload);
-30
View File
@@ -1,30 +0,0 @@
import matter from 'gray-matter';
// Eine posts-Zeile aus Supabase -> Hugo-Markdown (Frontmatter + Body).
// Mappt exakt die Felder, die OPENBUREAU im content/ nutzt. Nur gesetzte
// Felder landen im Frontmatter, damit die MD sauber bleibt.
export function rowToMarkdown(post, { draft = false } = {}) {
const fm = {
title: post.title,
// date als reines YYYY-MM-DD ausgeben (wie in den bestehenden Posts).
date: toDateOnly(post.date),
};
if (post.weight != null) fm.weight = post.weight;
if (Array.isArray(post.tags) && post.tags.length) fm.tags = post.tags;
if (post.summary) fm.summary = post.summary;
if (post.cover_image) fm.cover_image = post.cover_image;
if (post.layout) fm.layout = post.layout;
if (post.external) fm.external = post.external;
if (post.color) fm.color = post.color;
if (draft) fm.draft = true;
return matter.stringify(post.body || '', fm);
}
function toDateOnly(d) {
if (!d) return undefined;
// Akzeptiert Date, ISO-String oder "YYYY-MM-DD".
const s = typeof d === 'string' ? d : new Date(d).toISOString();
return s.slice(0, 10);
}
+29
View File
@@ -0,0 +1,29 @@
import { Hono } from 'hono';
import { listEntries, readEntry, writeEntry, entryExists } from '../files.js';
// Dateibasiert: liest/schreibt die echten .md unter content/.
const content = new Hono();
// Liste aller Einträge (Beiträge, Seiten, Rubriken).
content.get('/', async (c) => {
try { return c.json(await listEntries()); }
catch (e) { return c.json({ error: String(e.message || e) }, 500); }
});
// Einen Eintrag lesen: /api/content/entry?path=library/software/stack.md
content.get('/entry', async (c) => {
try { return c.json(await readEntry(c.req.query('path'))); }
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
});
// Anlegen oder überschreiben.
content.put('/entry', async (c) => {
const { path: rel, frontmatter, body } = await c.req.json();
try {
const created = !(await entryExists(rel));
const saved = await writeEntry(rel, frontmatter, body);
return c.json({ ok: true, path: saved, created });
} catch (e) { return c.json({ error: String(e.message || e) }, 400); }
});
export default content;
-50
View File
@@ -1,50 +0,0 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
// Minimales CRUD, damit der Publish/Preview-Flow ohne UI testbar ist.
// (Auth-Middleware kommt im nächsten Meilenstein.)
const posts = new Hono();
posts.get('/', async (c) => {
const { data, error } = await supabase
.from('posts')
.select('*')
.order('date', { ascending: false });
if (error) return c.json({ error: error.message }, 500);
return c.json(data);
});
posts.get('/:id', async (c) => {
const { data, error } = await supabase
.from('posts')
.select('*')
.eq('id', c.req.param('id'))
.single();
if (error) return c.json({ error: error.message }, 404);
return c.json(data);
});
posts.post('/', async (c) => {
const body = await c.req.json();
const { data, error } = await supabase
.from('posts')
.insert({ ...body, status: 'draft' })
.select()
.single();
if (error) return c.json({ error: error.message }, 400);
return c.json(data, 201);
});
posts.put('/:id', async (c) => {
const body = await c.req.json();
const { data, error } = await supabase
.from('posts')
.update({ ...body, updated_at: new Date().toISOString() })
.eq('id', c.req.param('id'))
.select()
.single();
if (error) return c.json({ error: error.message }, 400);
return c.json(data);
});
export default posts;
+7 -14
View File
@@ -1,24 +1,17 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { writePostFile } from '../content.js';
import { urlFor, safeRel } from '../files.js';
import { hugoBuild } from '../hugo.js';
// Echte Hugo-Vorschau: Post als draft:true in content/ schreiben und mit
// --buildDrafts nach preview/ bauen. Der Live-Build (public/) lässt den
// Draft weiterhin aus.
// Echte Hugo-Vorschau: ganze Site mit --buildDrafts nach preview/ bauen und die
// URL des Eintrags zurückgeben (so erscheinen auch draft:true-Einträge).
const preview = new Hono();
preview.post('/:id', async (c) => {
const id = c.req.param('id');
const { data: post, error } = await supabase
.from('posts').select('*').eq('id', id).single();
if (error || !post) return c.json({ error: 'Post nicht gefunden' }, 404);
preview.post('/', async (c) => {
const { path: rel } = await c.req.json();
try {
await writePostFile(post, { draft: true });
const safe = safeRel(rel);
const build = await hugoBuild({ dest: 'preview', drafts: true });
const url = `/_preview/library/${post.section}/${post.slug}/`;
return c.json({ ok: true, url, hugo: build.stdout });
return c.json({ ok: true, url: `/_preview${urlFor(safe)}`, hugo: build.stdout });
} catch (e) {
return c.json({ error: String(e.message || e) }, 500);
}
+7 -28
View File
@@ -1,38 +1,17 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { writePostFile } from '../content.js';
import { urlFor, safeRel } from '../files.js';
import { hugoBuild, gitCommit } from '../hugo.js';
// Publizieren: Post als live (draft:false) nach content/ schreiben, public/
// neu bauen, Status setzen und optional nach Gitea committen.
// Publizieren: public/ neu bauen (ohne Drafts) → live. Optional git-commit.
const publish = new Hono();
publish.post('/:id', async (c) => {
const id = c.req.param('id');
const { data: post, error } = await supabase
.from('posts').select('*').eq('id', id).single();
if (error || !post) return c.json({ error: 'Post nicht gefunden' }, 404);
publish.post('/', async (c) => {
const { path: rel } = await c.req.json();
try {
const file = await writePostFile(post, { draft: false });
const safe = safeRel(rel);
const build = await hugoBuild({ dest: 'public', drafts: false });
const { error: upErr } = await supabase
.from('posts')
.update({ status: 'published', published_at: new Date().toISOString() })
.eq('id', id);
if (upErr) return c.json({ error: upErr.message }, 500);
const git = await gitCommit(`cms: publish ${post.section}/${post.slug}`)
.catch((e) => ({ error: String(e.message || e) }));
return c.json({
ok: true,
path: file.replace(process.env.SITE_DIR || '/site', '').replace(/^\//, ''),
url: `/library/${post.section}/${post.slug}/`,
git,
hugo: build.stdout,
});
const git = await gitCommit(`cms: publish ${safe}`).catch((e) => ({ error: String(e.message || e) }));
return c.json({ ok: true, url: urlFor(safe), git, hugo: build.stdout });
} catch (e) {
return c.json({ error: String(e.message || e) }, 500);
}