diff --git a/cms/README.md b/cms/README.md index 1844901..e5c91c0 100644 --- a/cms/README.md +++ b/cms/README.md @@ -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//…`), 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 `. -| 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/` | +| 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/` | ## Stand diff --git a/cms/admin/src/App.jsx b/cms/admin/src/App.jsx index 17d24b6..01cec75 100644 --- a/cms/admin/src/App.jsx +++ b/cms/admin/src/App.jsx @@ -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
; + if (loading) return
; if (!session) return ; return ; } @@ -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 (
-
-

OPENBUREAU CMS

- setEmail(e.target.value)} autoFocus /> - setPassword(e.target.value)} /> - + +
OPENBUREAU
+
Redaktion
+ setEmail(e.target.value)} autoFocus /> + setPassword(e.target.value)} /> + {err &&

{err}

}
@@ -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 (
-
- OPENBUREAU CMS +
+ OPENBUREAU + Redaktion {email} - +
{current - ? { setCurrent(fromRow(row)); refresh(); }} - onMsg={setMsg} /> - :

Wähle links einen Post oder leg einen neuen an.

} + ? { setCurrent(loaded); refresh(); }} onMsg={setMsg} /> + :

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

}
@@ -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 (
+
+ {f.isNew ? ( + <> + + {f.type === 'beitrag' && ( + + )} + + + ) : ( +
{f.path}
+ )} +
+ + +
- -
-
- - - -
+
- - + + +
- + + +
- - - + +
-