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:
+15
-13
@@ -28,13 +28,16 @@ docker compose
|
|||||||
nicht — Bild-Uploads gehen auf Platte nach `static/images/`). Nachrüstbar durch
|
nicht — Bild-Uploads gehen auf Platte nach `static/images/`). Nachrüstbar durch
|
||||||
Kopieren der Service-Blöcke aus RAPPORT-SERVER.
|
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
|
Dateibasiert. Die echten `content/**/*.md` sind kanonisch — das CMS liest und
|
||||||
**generiertes Artefakt** — nicht von Hand editieren, wird beim Publish
|
schreibt sie direkt (Frontmatter via gray-matter). Damit erscheinen **alle**
|
||||||
überschrieben. Drafts liegen als `draft: true` in `content/` und werden vom
|
bestehenden Inhalte im Editor: Beiträge (`library/<rubrik>/…`), Seiten
|
||||||
Live-Build automatisch ausgelassen; nur der Preview-Build (`--buildDrafts`)
|
(`manifest.md`, `colophon.md`) und Rubriken (`_index.md`).
|
||||||
zeigt sie.
|
|
||||||
|
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
|
## Setup
|
||||||
|
|
||||||
@@ -79,14 +82,13 @@ Dann: Admin `…:8080/admin/` · Live `…:8080/` · Preview `…:8080/_preview/
|
|||||||
Alle `/api/*` (ausser `/health`) verlangen `Authorization: Bearer <supabase-token>`.
|
Alle `/api/*` (ausser `/health`) verlangen `Authorization: Bearer <supabase-token>`.
|
||||||
|
|
||||||
| Methode | Pfad | Zweck |
|
| Methode | Pfad | Zweck |
|
||||||
|---------|---------------------|----------------------------------------|
|
|---------|-------------------------------|----------------------------------------|
|
||||||
| GET | `/api/health` | Healthcheck (offen) |
|
| GET | `/api/health` | Healthcheck (offen) |
|
||||||
| GET | `/api/posts` | Alle Posts listen |
|
| GET | `/api/content` | Alle Einträge listen (Beiträge/Seiten/Rubriken) |
|
||||||
| GET | `/api/posts/:id` | Einen Post |
|
| GET | `/api/content/entry?path=…` | Einen Eintrag lesen (Frontmatter + Body) |
|
||||||
| POST | `/api/posts` | Post anlegen (Draft) |
|
| PUT | `/api/content/entry` | Eintrag anlegen/speichern (`{path, frontmatter, body}`) |
|
||||||
| PUT | `/api/posts/:id` | Post aktualisieren |
|
| POST | `/api/preview` | Preview-Build (`--buildDrafts`), liefert `/_preview/…` |
|
||||||
| POST | `/api/preview/:id` | Draft als `draft:true` schreiben + Preview-Build |
|
| POST | `/api/publish` | Public-Build → live + (opt.) git commit |
|
||||||
| POST | `/api/publish/:id` | Live schreiben + Public-Build + (opt.) git commit |
|
|
||||||
| POST | `/api/upload` | Bild → `static/images/`, liefert `/images/<name>` |
|
| POST | `/api/upload` | Bild → `static/images/`, liefert `/images/<name>` |
|
||||||
|
|
||||||
## Stand
|
## Stand
|
||||||
|
|||||||
+234
-139
@@ -1,23 +1,32 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { supabase } from './supabase.js';
|
import { supabase } from './supabase.js';
|
||||||
import { api } from './api.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 SECTIONS = ['buerofuehrung', 'software', 'theorie'];
|
||||||
const LAYOUTS = ['', 'text', 'image'];
|
const KIND_LABEL = { beitrag: 'Beiträge', seite: 'Seiten', rubrik: 'Rubriken' };
|
||||||
|
|
||||||
const EMPTY = {
|
const EMPTY = {
|
||||||
section: 'software',
|
isNew: true, path: '', type: 'beitrag', section: 'software', slug: '',
|
||||||
slug: '',
|
title: '', date: new Date().toISOString().slice(0, 10), weight: '',
|
||||||
title: '',
|
color: '', layout: 'text', tags: '', summary: '', description: '',
|
||||||
date: new Date().toISOString().slice(0, 10),
|
cover_image: '', external: '', toc: false, draft: true, body: '',
|
||||||
weight: '',
|
|
||||||
tags: '',
|
|
||||||
summary: '',
|
|
||||||
cover_image: '',
|
|
||||||
layout: 'text',
|
|
||||||
external: '',
|
|
||||||
color: '',
|
|
||||||
body: '',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -25,15 +34,12 @@ export default function App() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
supabase.auth.getSession().then(({ data }) => {
|
supabase.auth.getSession().then(({ data }) => { setSession(data.session); setLoading(false); });
|
||||||
setSession(data.session);
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
const { data: sub } = supabase.auth.onAuthStateChange((_e, s) => setSession(s));
|
const { data: sub } = supabase.auth.onAuthStateChange((_e, s) => setSession(s));
|
||||||
return () => sub.subscription.unsubscribe();
|
return () => sub.subscription.unsubscribe();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) return <div className="center">…</div>;
|
if (loading) return <div className="center muted">…</div>;
|
||||||
if (!session) return <Login />;
|
if (!session) return <Login />;
|
||||||
return <Dashboard email={session.user.email} />;
|
return <Dashboard email={session.user.email} />;
|
||||||
}
|
}
|
||||||
@@ -42,23 +48,19 @@ function Login() {
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [err, setErr] = useState(null);
|
const [err, setErr] = useState(null);
|
||||||
|
|
||||||
async function submit(e) {
|
async function submit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault(); setErr(null);
|
||||||
setErr(null);
|
|
||||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||||
if (error) setErr(error.message);
|
if (error) setErr(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="center">
|
<div className="center">
|
||||||
<form className="card login" onSubmit={submit}>
|
<form className="login" onSubmit={submit}>
|
||||||
<h1>OPENBUREAU CMS</h1>
|
<div className="login-brand">OPENBUREAU</div>
|
||||||
<input type="email" placeholder="E-Mail" value={email}
|
<div className="login-sub">Redaktion</div>
|
||||||
onChange={(e) => setEmail(e.target.value)} autoFocus />
|
<input type="email" placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} autoFocus />
|
||||||
<input type="password" placeholder="Passwort" value={password}
|
<input type="password" placeholder="Passwort" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||||
onChange={(e) => setPassword(e.target.value)} />
|
<button type="submit">Anmelden</button>
|
||||||
<button type="submit">Einloggen</button>
|
|
||||||
{err && <p className="err">{err}</p>}
|
{err && <p className="err">{err}</p>}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,49 +68,62 @@ function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Dashboard({ email }) {
|
function Dashboard({ email }) {
|
||||||
const [posts, setPosts] = useState([]);
|
const [entries, setEntries] = useState([]);
|
||||||
const [current, setCurrent] = useState(null); // post-Objekt oder null
|
const [current, setCurrent] = useState(null);
|
||||||
const [msg, setMsg] = useState(null);
|
const [msg, setMsg] = useState(null);
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try { setEntries(await api.list()); }
|
||||||
setPosts(await api.listPosts());
|
catch (e) { setMsg({ type: 'err', text: e.message }); }
|
||||||
} catch (e) {
|
|
||||||
setMsg({ type: 'err', text: e.message });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
useEffect(() => { refresh(); }, []);
|
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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<header>
|
<header className="topbar">
|
||||||
<strong>OPENBUREAU CMS</strong>
|
<span className="logo">OPENBUREAU</span>
|
||||||
|
<span className="logo-sub">Redaktion</span>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="muted">{email}</span>
|
<span className="muted">{email}</span>
|
||||||
<button onClick={() => supabase.auth.signOut()}>Logout</button>
|
<button className="ghost" onClick={() => supabase.auth.signOut()}>Abmelden</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="body">
|
<div className="body">
|
||||||
<aside>
|
<aside>
|
||||||
<button className="new" onClick={() => setCurrent({ ...EMPTY })}>+ Neuer Post</button>
|
<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">
|
<ul className="list">
|
||||||
{posts.map((p) => (
|
{groups[kind].map((e) => (
|
||||||
<li key={p.id} className={current?.id === p.id ? 'active' : ''}
|
<li key={e.path} className={current?.path === e.path ? 'active' : ''} onClick={() => open(e)}>
|
||||||
onClick={() => setCurrent(fromRow(p))}>
|
<span className="dot" style={{ background: e.color ? hexOf(e.color) : 'var(--line)' }} />
|
||||||
<span className={`dot ${p.status}`} />
|
<span className="t">{e.title}{e.draft && <em className="draft-tag">Entwurf</em>}</span>
|
||||||
<span className="t">{p.title || '(ohne Titel)'}</span>
|
{e.section && <span className="s">{e.section}</span>}
|
||||||
<span className="s">{p.section}</span>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
{current
|
{current
|
||||||
? <Editor key={current.id || 'new'} initial={current}
|
? <Editor key={current.path || 'new'} initial={current}
|
||||||
onSaved={(row) => { setCurrent(fromRow(row)); refresh(); }}
|
onSaved={(loaded) => { setCurrent(loaded); refresh(); }} onMsg={setMsg} />
|
||||||
onMsg={setMsg} />
|
: <div className="empty"><p>Wähle links einen Eintrag — oder leg einen neuen Beitrag an.</p></div>}
|
||||||
: <p className="muted pad">Wähle links einen Post oder leg einen neuen an.</p>}
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,111 +133,123 @@ function Dashboard({ email }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Editor({ initial, onSaved, onMsg }) {
|
function Editor({ initial, onSaved, onMsg }) {
|
||||||
const [post, setPost] = useState(initial);
|
const [f, setF] = useState(initial);
|
||||||
const [previewUrl, setPreviewUrl] = useState(null);
|
const [previewUrl, setPreviewUrl] = useState(null);
|
||||||
const [busy, setBusy] = useState(false);
|
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) {
|
function currentPath() {
|
||||||
const file = e.target.files?.[0];
|
if (!f.isNew) return f.path;
|
||||||
if (!file) return;
|
const slug = (f.slug || '').trim();
|
||||||
setBusy(true);
|
if (!slug) return '';
|
||||||
try {
|
return f.type === 'beitrag' ? `library/${f.section}/${slug}.md` : `${slug}.md`;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
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);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const payload = toRow(post);
|
await api.save(path, buildFrontmatter(f), f.body);
|
||||||
const row = post.id
|
const loaded = fromRead(await api.read(path));
|
||||||
? await api.updatePost(post.id, payload)
|
onSaved(loaded);
|
||||||
: await api.createPost(payload);
|
setF(loaded);
|
||||||
onSaved(row);
|
|
||||||
onMsg({ type: 'ok', text: 'Gespeichert.' });
|
onMsg({ type: 'ok', text: 'Gespeichert.' });
|
||||||
return row;
|
return path;
|
||||||
} catch (e) {
|
} catch (e) { onMsg({ type: 'err', text: e.message }); return null; }
|
||||||
onMsg({ type: 'err', text: e.message });
|
finally { setBusy(false); }
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function preview() {
|
async function preview() {
|
||||||
const row = await save();
|
const path = await save();
|
||||||
if (!row) return;
|
if (!path) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.preview(row.id);
|
const res = await api.preview(path);
|
||||||
// Cache-Buster, damit der frische Build geladen wird.
|
|
||||||
setPreviewUrl(`${res.url}?t=${Date.now()}`);
|
setPreviewUrl(`${res.url}?t=${Date.now()}`);
|
||||||
} catch (e) {
|
} catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
onMsg({ type: 'err', text: e.message });
|
finally { setBusy(false); }
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publish() {
|
async function publish() {
|
||||||
const row = await save();
|
const path = await save();
|
||||||
if (!row) return;
|
if (!path) return;
|
||||||
if (!confirm('Diesen Post live publizieren?')) return;
|
if (!confirm('Diesen Eintrag live publizieren?')) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.publish(row.id);
|
const res = await api.publish(path);
|
||||||
onMsg({ type: 'ok', text: `Live: ${res.url}` });
|
onMsg({ type: 'ok', text: `Live: ${res.url}` });
|
||||||
} catch (e) {
|
} catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
onMsg({ type: 'err', text: e.message });
|
finally { setBusy(false); }
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor">
|
<div className="editor">
|
||||||
<div className="fields">
|
<div className="fields">
|
||||||
<div className="row">
|
<div className="path-row">
|
||||||
<label>Titel<input value={post.title} onChange={set('title')} /></label>
|
{f.isNew ? (
|
||||||
<label className="sm">Section
|
<>
|
||||||
<select value={post.section} onChange={set('section')}>
|
<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>)}
|
{SECTIONS.map((s) => <option key={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
|
<label>Slug<input value={f.slug} onChange={set('slug')} placeholder="z.B. neuer-beitrag" /></label>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="pathlabel">{f.path}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="big">Titel<input value={f.title} onChange={set('title')} /></label>
|
||||||
|
|
||||||
<div className="row">
|
<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={f.date} onChange={set('date')} /></label>
|
||||||
<label className="sm">Datum<input type="date" value={post.date} onChange={set('date')} /></label>
|
<label className="xs">Reihenfolge<input type="number" value={f.weight} onChange={set('weight')} placeholder="weight" /></label>
|
||||||
<label className="xs">Weight<input type="number" value={post.weight} onChange={set('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>
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="sm">Layout
|
<label className="sm">Layout
|
||||||
<select value={post.layout} onChange={set('layout')}>
|
<select value={f.layout} onChange={set('layout')}>
|
||||||
{LAYOUTS.map((l) => <option key={l} value={l}>{l || '(default)'}</option>)}
|
{LAYOUTS.map((l) => <option key={l} value={l}>{l || '(automatisch)'}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="sm">Color<input value={post.color} onChange={set('color')} placeholder="kusa / yuyake" /></label>
|
<label>Tags<input value={f.tags} onChange={set('tags')} placeholder="komma, getrennt" /></label>
|
||||||
<label>Tags<input value={post.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>
|
</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">
|
<div className="row">
|
||||||
<label>Cover-Bild
|
<label>Cover-Bild<input value={f.cover_image} onChange={set('cover_image')} placeholder="/images/…jpg" /></label>
|
||||||
<input value={post.cover_image} onChange={set('cover_image')} placeholder="/images/...jpg" />
|
<label>Externer Link<input value={f.external} onChange={set('external')} placeholder="https://…" /></label>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
<label className="grow">Inhalt (Markdown)
|
|
||||||
<textarea value={post.body} onChange={set('body')} spellCheck={false} />
|
<MarkdownEditor
|
||||||
</label>
|
value={f.body}
|
||||||
|
onChange={(body) => setF((p) => ({ ...p, body }))}
|
||||||
|
onUpload={async (file) => (await api.upload(file)).url}
|
||||||
|
onMsg={onMsg}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button onClick={save} disabled={busy}>Speichern</button>
|
<button onClick={save} disabled={busy}>Speichern</button>
|
||||||
<button onClick={preview} disabled={busy}>Vorschau</button>
|
<button onClick={preview} disabled={busy}>Vorschau</button>
|
||||||
@@ -233,34 +260,102 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
<div className="preview">
|
<div className="preview">
|
||||||
{previewUrl
|
{previewUrl
|
||||||
? <iframe title="Vorschau" src={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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- DB-Zeile <-> Formular ---
|
// ── Markdown-Editor mit Formatierungs-Toolbar ──────────────────────────────
|
||||||
function fromRow(row) {
|
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 {
|
return {
|
||||||
...EMPTY, ...row,
|
isNew: false, path: r.path, type: 'beitrag', section: '', slug: '',
|
||||||
weight: row.weight ?? '',
|
title: fm.title || '', date: fm.date ? String(fm.date).slice(0, 10) : '',
|
||||||
tags: Array.isArray(row.tags) ? row.tags.join(', ') : '',
|
weight: fm.weight ?? '', color: fm.color || '', layout: fm.layout || '',
|
||||||
date: row.date ? String(row.date).slice(0, 10) : EMPTY.date,
|
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) {
|
function buildFrontmatter(f) {
|
||||||
return {
|
const fm = { title: f.title };
|
||||||
section: post.section,
|
if (f.date) fm.date = f.date;
|
||||||
slug: post.slug,
|
if (f.weight !== '' && f.weight != null) fm.weight = Number(f.weight);
|
||||||
title: post.title,
|
const tags = f.tags ? f.tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
|
||||||
date: post.date,
|
if (tags.length) fm.tags = tags;
|
||||||
weight: post.weight === '' ? null : Number(post.weight),
|
if (f.summary) fm.summary = f.summary;
|
||||||
tags: post.tags ? post.tags.split(',').map((t) => t.trim()).filter(Boolean) : [],
|
if (f.description) fm.description = f.description;
|
||||||
summary: post.summary || null,
|
if (f.cover_image) fm.cover_image = f.cover_image;
|
||||||
cover_image: post.cover_image || null,
|
if (f.layout) fm.layout = f.layout;
|
||||||
layout: post.layout || null,
|
if (f.external) fm.external = f.external;
|
||||||
external: post.external || null,
|
if (f.color) fm.color = f.color;
|
||||||
color: post.color || null,
|
if (f.toc) fm.toc = true;
|
||||||
body: post.body || '',
|
if (f.draft) fm.draft = true;
|
||||||
};
|
return fm;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ async function call(path, options = {}) {
|
|||||||
return json;
|
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) {
|
async function uploadFile(file) {
|
||||||
const { data } = await supabase.auth.getSession();
|
const { data } = await supabase.auth.getSession();
|
||||||
const token = data?.session?.access_token;
|
const token = data?.session?.access_token;
|
||||||
@@ -34,10 +34,11 @@ async function uploadFile(file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
listPosts: () => call('/posts'),
|
list: () => call('/content'),
|
||||||
createPost: (post) => call('/posts', { method: 'POST', body: JSON.stringify(post) }),
|
read: (path) => call(`/content/entry?path=${encodeURIComponent(path)}`),
|
||||||
updatePost: (id, post) => call(`/posts/${id}`, { method: 'PUT', body: JSON.stringify(post) }),
|
save: (path, frontmatter, body) =>
|
||||||
preview: (id) => call(`/preview/${id}`, { method: 'POST' }),
|
call('/content/entry', { method: 'PUT', body: JSON.stringify({ path, frontmatter, body }) }),
|
||||||
publish: (id) => call(`/publish/${id}`, { method: 'POST' }),
|
preview: (path) => call('/preview', { method: 'POST', body: JSON.stringify({ path }) }),
|
||||||
|
publish: (path) => call('/publish', { method: 'POST', body: JSON.stringify({ path }) }),
|
||||||
upload: uploadFile,
|
upload: uploadFile,
|
||||||
};
|
};
|
||||||
|
|||||||
+112
-48
@@ -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 {
|
:root {
|
||||||
--bg: #14110e;
|
--serif: 'Newsreader', Georgia, serif;
|
||||||
--panel: #1d1a16;
|
--sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
--line: #322c25;
|
--display: 'Space Grotesk', 'Inter', sans-serif;
|
||||||
--text: #ece6dd;
|
--mono: 'IBM Plex Mono', ui-monospace, monospace;
|
||||||
--muted: #8a8078;
|
|
||||||
--accent: #c8543a;
|
--bg: hsl(35 14% 96%);
|
||||||
--ok: #5a8a4a;
|
--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; }
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #root { height: 100%; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font: 15px/1.5 -apple-system, system-ui, sans-serif;
|
font-family: var(--sans);
|
||||||
background: var(--bg);
|
font-size: 14.5px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
.center { display: grid; place-items: center; min-height: 100vh; }
|
button, input, select, textarea { font-family: inherit; font-size: inherit; color: var(--text); }
|
||||||
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 28px; }
|
.muted { color: var(--muted); }
|
||||||
.login { display: flex; flex-direction: column; gap: 12px; width: 300px; }
|
.center { display: grid; place-items: center; height: 100%; }
|
||||||
.login h1 { font-size: 18px; margin: 0 0 8px; letter-spacing: .08em; }
|
|
||||||
|
|
||||||
input, select, textarea, button {
|
/* ── Login ── */
|
||||||
font: inherit; color: var(--text);
|
.login {
|
||||||
background: #110e0b; border: 1px solid var(--line);
|
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
|
||||||
border-radius: 6px; padding: 8px 10px;
|
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); }
|
.login-brand { font-family: var(--display); font-weight: 700; letter-spacing: .14em; font-size: 20px; }
|
||||||
button { background: #2a241e; cursor: pointer; }
|
.login-sub { font-family: var(--serif); font-style: italic; color: var(--muted); margin-bottom: 10px; }
|
||||||
button:hover { border-color: var(--accent); }
|
.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:disabled { opacity: .5; cursor: default; }
|
||||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
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; }
|
/* ── App-Rahmen ── */
|
||||||
header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--line); }
|
.app { display: flex; flex-direction: column; height: 100%; }
|
||||||
.spacer { flex: 1; }
|
.topbar {
|
||||||
.muted { color: var(--muted); }
|
display: flex; align-items: center; gap: 12px; padding: 0 18px; height: 54px;
|
||||||
.pad { padding: 24px; }
|
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; }
|
.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 { 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 { display: flex; align-items: center; gap: 9px; padding: 8px 9px; border-radius: 8px; cursor: pointer; }
|
||||||
.list li:hover { background: #221d18; }
|
.list li:hover { background: var(--panel); }
|
||||||
.list li.active { background: #2a241e; }
|
.list li.active { background: var(--panel); box-shadow: inset 2px 0 0 var(--accent); }
|
||||||
.list .t { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.list .dot { width: 9px; height: 9px; border-radius: 50%; flex: none; border: 1px solid rgba(0,0,0,.12); }
|
||||||
.list .s { color: var(--muted); font-size: 12px; }
|
.list .t { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--serif); font-size: 15px; }
|
||||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
|
.list .s { font-size: 11px; color: var(--muted); }
|
||||||
.dot.published { background: var(--ok); }
|
.draft-tag { font-style: normal; font-size: 10px; color: #b8902f; margin-left: 6px; font-family: var(--sans); }
|
||||||
.dot.draft { background: #b89030; }
|
|
||||||
|
|
||||||
main { flex: 1; min-width: 0; }
|
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%; }
|
.editor { display: flex; height: 100%; }
|
||||||
.fields { width: 50%; padding: 16px; overflow: auto; display: flex; flex-direction: column; gap: 12px; }
|
.fields { width: 52%; padding: 22px 24px; overflow: auto; display: flex; flex-direction: column; gap: 14px; }
|
||||||
.row { display: flex; gap: 10px; }
|
.row { display: flex; gap: 12px; align-items: flex-end; }
|
||||||
label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--muted); flex: 1; }
|
label { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--muted); flex: 1; }
|
||||||
label.sm { flex: 0 0 140px; }
|
label.sm { flex: 0 0 150px; } label.xs { flex: 0 0 110px; }
|
||||||
label.xs { flex: 0 0 90px; }
|
label.check { flex-direction: row; align-items: center; gap: 7px; flex: 0 0 auto; white-space: nowrap; }
|
||||||
label.grow { flex: 1; }
|
label.check input { width: auto; }
|
||||||
label input, label select, label textarea { color: var(--text); }
|
label.big input { font-family: var(--serif); font-size: 22px; font-weight: 600; padding: 11px 13px; }
|
||||||
textarea { flex: 1; min-height: 240px; resize: vertical; font-family: ui-monospace, monospace; }
|
|
||||||
.actions { display: flex; gap: 8px; }
|
|
||||||
|
|
||||||
.preview { width: 50%; border-left: 1px solid var(--line); }
|
.path-row { display: flex; gap: 12px; align-items: flex-end; }
|
||||||
.preview iframe { width: 100%; height: 100%; border: 0; background: #fff; }
|
.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.ok { background: var(--ok); }
|
||||||
.toast.err { background: var(--accent); }
|
.toast.err { background: var(--accent); }
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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,7 +2,7 @@ import { serve } from '@hono/node-server';
|
|||||||
import { serveStatic } from '@hono/node-server/serve-static';
|
import { serveStatic } from '@hono/node-server/serve-static';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
import posts from './routes/posts.js';
|
import content from './routes/content.js';
|
||||||
import preview from './routes/preview.js';
|
import preview from './routes/preview.js';
|
||||||
import publish from './routes/publish.js';
|
import publish from './routes/publish.js';
|
||||||
import upload from './routes/upload.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' }));
|
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.
|
// Alles unter /api/* (ausser /health oben) braucht ein gültiges Supabase-Token.
|
||||||
app.use('/api/*', requireAuth);
|
app.use('/api/*', requireAuth);
|
||||||
app.route('/api/posts', posts);
|
app.route('/api/content', content);
|
||||||
app.route('/api/preview', preview);
|
app.route('/api/preview', preview);
|
||||||
app.route('/api/publish', publish);
|
app.route('/api/publish', publish);
|
||||||
app.route('/api/upload', upload);
|
app.route('/api/upload', upload);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
|
||||||
@@ -1,24 +1,17 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { supabase } from '../supabase.js';
|
import { urlFor, safeRel } from '../files.js';
|
||||||
import { writePostFile } from '../content.js';
|
|
||||||
import { hugoBuild } from '../hugo.js';
|
import { hugoBuild } from '../hugo.js';
|
||||||
|
|
||||||
// Echte Hugo-Vorschau: Post als draft:true in content/ schreiben und mit
|
// Echte Hugo-Vorschau: ganze Site mit --buildDrafts nach preview/ bauen und die
|
||||||
// --buildDrafts nach preview/ bauen. Der Live-Build (public/) lässt den
|
// URL des Eintrags zurückgeben (so erscheinen auch draft:true-Einträge).
|
||||||
// Draft weiterhin aus.
|
|
||||||
const preview = new Hono();
|
const preview = new Hono();
|
||||||
|
|
||||||
preview.post('/:id', async (c) => {
|
preview.post('/', async (c) => {
|
||||||
const id = c.req.param('id');
|
const { path: rel } = await c.req.json();
|
||||||
const { data: post, error } = await supabase
|
|
||||||
.from('posts').select('*').eq('id', id).single();
|
|
||||||
if (error || !post) return c.json({ error: 'Post nicht gefunden' }, 404);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await writePostFile(post, { draft: true });
|
const safe = safeRel(rel);
|
||||||
const build = await hugoBuild({ dest: 'preview', drafts: true });
|
const build = await hugoBuild({ dest: 'preview', drafts: true });
|
||||||
const url = `/_preview/library/${post.section}/${post.slug}/`;
|
return c.json({ ok: true, url: `/_preview${urlFor(safe)}`, hugo: build.stdout });
|
||||||
return c.json({ ok: true, url, hugo: build.stdout });
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return c.json({ error: String(e.message || e) }, 500);
|
return c.json({ error: String(e.message || e) }, 500);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,17 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { supabase } from '../supabase.js';
|
import { urlFor, safeRel } from '../files.js';
|
||||||
import { writePostFile } from '../content.js';
|
|
||||||
import { hugoBuild, gitCommit } from '../hugo.js';
|
import { hugoBuild, gitCommit } from '../hugo.js';
|
||||||
|
|
||||||
// Publizieren: Post als live (draft:false) nach content/ schreiben, public/
|
// Publizieren: public/ neu bauen (ohne Drafts) → live. Optional git-commit.
|
||||||
// neu bauen, Status setzen und optional nach Gitea committen.
|
|
||||||
const publish = new Hono();
|
const publish = new Hono();
|
||||||
|
|
||||||
publish.post('/:id', async (c) => {
|
publish.post('/', async (c) => {
|
||||||
const id = c.req.param('id');
|
const { path: rel } = await c.req.json();
|
||||||
const { data: post, error } = await supabase
|
|
||||||
.from('posts').select('*').eq('id', id).single();
|
|
||||||
if (error || !post) return c.json({ error: 'Post nicht gefunden' }, 404);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const file = await writePostFile(post, { draft: false });
|
const safe = safeRel(rel);
|
||||||
const build = await hugoBuild({ dest: 'public', drafts: false });
|
const build = await hugoBuild({ dest: 'public', drafts: false });
|
||||||
|
const git = await gitCommit(`cms: publish ${safe}`).catch((e) => ({ error: String(e.message || e) }));
|
||||||
const { error: upErr } = await supabase
|
return c.json({ ok: true, url: urlFor(safe), git, hugo: build.stdout });
|
||||||
.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,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return c.json({ error: String(e.message || e) }, 500);
|
return c.json({ error: String(e.message || e) }, 500);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user