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,50 @@
|
||||
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;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../supabase.js';
|
||||
import { writePostFile } from '../content.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.
|
||||
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);
|
||||
|
||||
try {
|
||||
await writePostFile(post, { draft: true });
|
||||
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 });
|
||||
} catch (e) {
|
||||
return c.json({ error: String(e.message || e) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default preview;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../supabase.js';
|
||||
import { writePostFile } from '../content.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.
|
||||
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);
|
||||
|
||||
try {
|
||||
const file = await writePostFile(post, { draft: false });
|
||||
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,
|
||||
});
|
||||
} catch (e) {
|
||||
return c.json({ error: String(e.message || e) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default publish;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Hono } from 'hono';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
|
||||
// Bild-Upload → static/images/<name>. Hugo kopiert das beim Build nach
|
||||
// public/images/, cover_image referenziert es als /images/<name>.
|
||||
const upload = new Hono();
|
||||
|
||||
upload.post('/', async (c) => {
|
||||
const body = await c.req.parseBody();
|
||||
const file = body['file'];
|
||||
if (!file || typeof file === 'string') return c.json({ error: 'Keine Datei' }, 400);
|
||||
|
||||
const name = safeName(file.name);
|
||||
const dir = path.join(SITE_DIR, 'static', 'images');
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(dir, name), Buffer.from(await file.arrayBuffer()));
|
||||
|
||||
return c.json({ url: `/images/${name}` });
|
||||
});
|
||||
|
||||
// Sicherer Dateiname: nur basename, kleingeschrieben, ohne Pfad/Sonderzeichen.
|
||||
function safeName(raw) {
|
||||
const base = path.basename(String(raw || 'bild'));
|
||||
const cleaned = base
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return cleaned || 'bild';
|
||||
}
|
||||
|
||||
export default upload;
|
||||
Reference in New Issue
Block a user