cms: headless CMS vor Hugo (Supabase + Node-API + React-Admin)
All-in-One docker-compose-Stack (Muster von RAPPORT-SERVER gespiegelt): db/auth/rest/kong + cms-Service (Node-API + Hugo-Binary 0.161.1 + Admin-SPA). - DB-backed: posts-Tabelle kanonisch, MD ist generiertes Artefakt - echte Hugo-Vorschau via draft:true + --buildDrafts → /_preview - Publish: DB → content/library/<section>/<slug>.md → hugo build → live - Bild-Upload nach static/images/, Supabase-Auth schützt /api/* - Proxmox-LXC-Script: legt Container an, generiert Secrets, startet Stack Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OPENBUREAU — CMS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1936
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "openbureau-cms-admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.47.10",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from './supabase.js';
|
||||
import { api } from './api.js';
|
||||
|
||||
const SECTIONS = ['buerofuehrung', 'software', 'theorie'];
|
||||
const LAYOUTS = ['', 'text', 'image'];
|
||||
|
||||
const EMPTY = {
|
||||
section: 'software',
|
||||
slug: '',
|
||||
title: '',
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
weight: '',
|
||||
tags: '',
|
||||
summary: '',
|
||||
cover_image: '',
|
||||
layout: 'text',
|
||||
external: '',
|
||||
color: '',
|
||||
body: '',
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
setSession(data.session);
|
||||
setLoading(false);
|
||||
});
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_e, s) => setSession(s));
|
||||
return () => sub.subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="center">…</div>;
|
||||
if (!session) return <Login />;
|
||||
return <Dashboard email={session.user.email} />;
|
||||
}
|
||||
|
||||
function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
async function submit(e) {
|
||||
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>
|
||||
{err && <p className="err">{err}</p>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dashboard({ email }) {
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [current, setCurrent] = useState(null); // post-Objekt oder null
|
||||
const [msg, setMsg] = useState(null);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
setPosts(await api.listPosts());
|
||||
} catch (e) {
|
||||
setMsg({ type: 'err', text: e.message });
|
||||
}
|
||||
}
|
||||
useEffect(() => { refresh(); }, []);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header>
|
||||
<strong>OPENBUREAU CMS</strong>
|
||||
<span className="spacer" />
|
||||
<span className="muted">{email}</span>
|
||||
<button onClick={() => supabase.auth.signOut()}>Logout</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>
|
||||
</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>}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{msg && <div className={`toast ${msg.type}`} onClick={() => setMsg(null)}>{msg.text}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Editor({ initial, onSaved, onMsg }) {
|
||||
const [post, setPost] = useState(initial);
|
||||
const [previewUrl, setPreviewUrl] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = (k) => (e) => setPost({ ...post, [k]: 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);
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = toRow(post);
|
||||
const row = post.id
|
||||
? await api.updatePost(post.id, payload)
|
||||
: await api.createPost(payload);
|
||||
onSaved(row);
|
||||
onMsg({ type: 'ok', text: 'Gespeichert.' });
|
||||
return row;
|
||||
} catch (e) {
|
||||
onMsg({ type: 'err', text: e.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function preview() {
|
||||
const row = await save();
|
||||
if (!row) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.preview(row.id);
|
||||
// Cache-Buster, damit der frische Build geladen wird.
|
||||
setPreviewUrl(`${res.url}?t=${Date.now()}`);
|
||||
} 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;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.publish(row.id);
|
||||
onMsg({ type: 'ok', text: `Live: ${res.url}` });
|
||||
} catch (e) {
|
||||
onMsg({ type: 'err', text: e.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="editor">
|
||||
<div className="fields">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
<label>Summary<input value={post.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>
|
||||
</div>
|
||||
<label className="grow">Inhalt (Markdown)
|
||||
<textarea value={post.body} onChange={set('body')} spellCheck={false} />
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button onClick={save} disabled={busy}>Speichern</button>
|
||||
<button onClick={preview} disabled={busy}>Vorschau</button>
|
||||
<button className="primary" onClick={publish} disabled={busy}>Publizieren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="preview">
|
||||
{previewUrl
|
||||
? <iframe title="Vorschau" src={previewUrl} />
|
||||
: <p className="muted pad">Vorschau erscheint hier nach Klick auf „Vorschau".</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- DB-Zeile <-> Formular ---
|
||||
function fromRow(row) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
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 || '',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { supabase } from './supabase.js';
|
||||
|
||||
// Ruft die CMS-API (gleiche Origin) mit dem aktuellen Supabase-Token auf.
|
||||
async function call(path, options = {}) {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
const token = data?.session?.access_token;
|
||||
const res = await fetch(`/api${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
return json;
|
||||
}
|
||||
|
||||
// Datei-Upload: kein JSON, der Browser setzt den multipart-Header selbst.
|
||||
async function uploadFile(file) {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
const token = data?.session?.access_token;
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
return json;
|
||||
}
|
||||
|
||||
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' }),
|
||||
upload: uploadFile,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
:root {
|
||||
--bg: #14110e;
|
||||
--panel: #1d1a16;
|
||||
--line: #322c25;
|
||||
--text: #ece6dd;
|
||||
--muted: #8a8078;
|
||||
--accent: #c8543a;
|
||||
--ok: #5a8a4a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font: 15px/1.5 -apple-system, system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.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; }
|
||||
|
||||
input, select, textarea, button {
|
||||
font: inherit; color: var(--text);
|
||||
background: #110e0b; border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 8px 10px;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline: 1px solid var(--accent); }
|
||||
button { background: #2a241e; cursor: pointer; }
|
||||
button:hover { border-color: var(--accent); }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.err { color: var(--accent); margin: 0; }
|
||||
|
||||
.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; }
|
||||
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
main { flex: 1; min-width: 0; }
|
||||
.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; }
|
||||
|
||||
.preview { width: 50%; border-left: 1px solid var(--line); }
|
||||
.preview iframe { width: 100%; height: 100%; border: 0; background: #fff; }
|
||||
|
||||
.toast { position: fixed; bottom: 18px; right: 18px; padding: 10px 16px; border-radius: 8px; cursor: pointer; }
|
||||
.toast.ok { background: var(--ok); }
|
||||
.toast.err { background: var(--accent); }
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
// Öffentliche Browser-Werte (zur Build-Zeit von Vite eingesetzt). Der anon-Key
|
||||
// ist per Design öffentlich; die echte Autorität liegt server-seitig.
|
||||
const url = import.meta.env.VITE_SUPABASE_URL;
|
||||
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
|
||||
export const supabase = createClient(url, anonKey);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// base /admin/ — die SPA wird vom CMS-Container unter /admin serviert.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/admin/',
|
||||
server: {
|
||||
// Dev: API + /_preview vom laufenden Container durchreichen.
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
'/_preview': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user