Compare commits
40 Commits
9163f5c90d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b37edb6bda | |||
| 6aedf21bf6 | |||
| fed003ae31 | |||
| 685592362a | |||
| 2f639c068b | |||
| f518eb7a13 | |||
| 3d902a0bbe | |||
| 42110059c7 | |||
| ecf7710b8e | |||
| ab78c84296 | |||
| 80eca1bc7c | |||
| 2bf27f01be | |||
| 51bf175450 | |||
| d6c5b2edb4 | |||
| 42f2823ff0 | |||
| 1709dc093c | |||
| 46ee91279e | |||
| 827a04cfe8 | |||
| 98955bc097 | |||
| 886c090a8a | |||
| f8c82ad4a2 | |||
| 04bb79bcfa | |||
| 30fc688622 | |||
| 234ed52fa8 | |||
| fc422c78d0 | |||
| 6e4bb06f5f | |||
| d5f35bb9f8 | |||
| ef024921ab | |||
| 42de32888c | |||
| fbf8ee1ebf | |||
| 790141bafe | |||
| 54f03270d0 | |||
| b4fdc8c200 | |||
| 0f80378a7c | |||
| 3dd8d5edd4 | |||
| 0f574bf8a7 | |||
| 9c9b7e03bd | |||
| d9ba2f7bbe | |||
| 37fdc9019c | |||
| 1fb4556ac1 |
@@ -11,6 +11,8 @@ hugo_stats.json
|
||||
|
||||
# Node (CMS)
|
||||
node_modules/
|
||||
# Admin-SPA Build-Output (wird im Container gebaut)
|
||||
cms/admin/dist/
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: "{{ replace .File.ContentBaseName `-` ` ` | title }}"
|
||||
group: "Allgemein"
|
||||
summary: ""
|
||||
toc: false
|
||||
---
|
||||
+134
-20
@@ -160,8 +160,8 @@ a:hover {
|
||||
.site-header {
|
||||
background: var(--color-dark-panel);
|
||||
color: var(--color-dark-panel-text);
|
||||
/* oben etwas Luft über dem Logo, unten nur 1–2px unter dem Menü */
|
||||
padding: 0.4rem 0 2px;
|
||||
/* etwas Luft über dem Logo und unter dem Menü */
|
||||
padding: 0.7rem 0 0.35rem;
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
/* inner 3-col grid matches body so wordmark/nav align with content column */
|
||||
@@ -192,7 +192,7 @@ a:hover {
|
||||
padding: 0;
|
||||
display: block;
|
||||
justify-self: center;
|
||||
width: clamp(140px, 18vw, 200px);
|
||||
width: clamp(154px, 19.8vw, 220px);
|
||||
aspect-ratio: 1412 / 231;
|
||||
height: auto;
|
||||
background-image: url("/logo/logo.svg");
|
||||
@@ -211,8 +211,8 @@ a:hover {
|
||||
|
||||
.site-header .site-nav {
|
||||
justify-self: center;
|
||||
/* etwas Luft zwischen Logo und Menü. */
|
||||
margin-top: 0.1em;
|
||||
/* Luft zwischen Logo und Menü. */
|
||||
margin-top: 0.32em;
|
||||
}
|
||||
|
||||
.wordmark-link:focus-visible { outline: 2px dotted var(--color-text-muted); outline-offset: 4px; }
|
||||
@@ -704,6 +704,77 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
||||
.dialog-logout { font: inherit; cursor: pointer; padding: 0.55em 1.1em; border-radius: 999px; background: none; border: 1px solid var(--color-border); color: var(--color-text-muted); }
|
||||
.dialog-replychip { align-self: flex-start; font-size: var(--font-size-small); cursor: pointer; padding: 0.25em 0.8em; border-radius: 999px; border: 1px solid var(--accent); color: var(--accent); background: none; }
|
||||
|
||||
/* ── Dialog: Lade-Skelett, Links im Text, Composer-Hinweis ── */
|
||||
.dialog-skel { display: flex; flex-direction: column; gap: 1.1em; padding: 0.7em 0; }
|
||||
.dialog-skel-line {
|
||||
height: 1.05em; width: 100%; border-radius: 6px;
|
||||
background: linear-gradient(90deg, var(--color-border) 25%, var(--color-bg-secondary) 37%, var(--color-border) 63%);
|
||||
background-size: 400% 100%; animation: dialog-shimmer 1.4s ease infinite;
|
||||
}
|
||||
.dialog-skel-line:nth-child(3n) { width: 68%; }
|
||||
.dialog-skel-line:nth-child(3n+1) { width: 92%; }
|
||||
@keyframes dialog-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { .dialog-skel-line { animation: none; } }
|
||||
.dialog-body .dialog-link { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; word-break: break-word; }
|
||||
.dialog-hint { font-size: var(--font-size-small); color: var(--color-text-muted); align-self: center; opacity: 0.7; }
|
||||
.dialog-spacer { flex: 1; }
|
||||
|
||||
/* ── Library: gleiches Gerüst wie Archiv (Übersicht = .atlas, Eintrag = .single).
|
||||
Eintrags-Fuss: Gruppe + weitere Einträge + bearbeiten-Link. ── */
|
||||
.entry-foot { margin-top: var(--spacing-lg); padding-top: var(--spacing-sm); border-top: 1px solid var(--color-border);
|
||||
display: flex; gap: 0.6em 1.2em; flex-wrap: wrap; align-items: baseline; font-size: var(--font-size-small); color: var(--color-text-muted); }
|
||||
.entry-foot .entry-more { flex: 1; min-width: 14em; }
|
||||
.entry-foot .entry-more a { color: var(--accent); text-decoration: none; }
|
||||
.entry-foot .entry-more a:hover { text-decoration: underline; text-underline-offset: 0.2em; }
|
||||
.entry-foot a { color: var(--color-text-muted); text-decoration: none; }
|
||||
.entry-foot a:hover { color: var(--accent); }
|
||||
|
||||
/* Wiki-Links in Library-Einträgen */
|
||||
.wikilink { color: var(--section-color, var(--accent)); text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 0.2em; }
|
||||
.wikilink:hover { text-decoration-style: solid; }
|
||||
.wikilink-missing { color: var(--color-text-muted); border-bottom: 1px dashed currentColor; }
|
||||
|
||||
/* Querverweise: „Siehe auch" + „Erwähnt in" */
|
||||
.entry-links { margin-top: var(--spacing-md); padding: 0.7em 1em;
|
||||
background: color-mix(in oklab, var(--section-color, var(--accent)) 8%, transparent);
|
||||
border-left: 3px solid var(--section-color, var(--accent)); border-radius: 0 8px 8px 0; }
|
||||
.entry-links + .entry-links { margin-top: 0.5em; }
|
||||
.entry-links ul { list-style: none; margin: 0.3em 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.25em 0.8em; }
|
||||
.entry-links li { font-size: var(--font-size-small); }
|
||||
.entry-links a { color: var(--section-color, var(--accent)); text-decoration: none; }
|
||||
.entry-links a:hover { text-decoration: underline; text-underline-offset: 0.2em; }
|
||||
.entry-links-label { font-size: var(--font-size-small); font-weight: 600; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
/* Library-Übersicht: Suchfeld + Kategorie-Pills */
|
||||
.lib-filter { margin-bottom: var(--spacing-sm); display: flex; flex-direction: column; gap: 0.5em; }
|
||||
.lib-search { width: 100%; padding: 0.45em 0.8em; border: 1px solid var(--color-border); border-radius: 6px;
|
||||
background: var(--color-bg-secondary); color: var(--color-text-primary); font-size: var(--font-size-base); font-family: inherit; }
|
||||
.lib-search:focus { outline: none; border-color: var(--section-color, var(--accent)); }
|
||||
.lib-pills { display: flex; flex-wrap: wrap; gap: 0.3em; }
|
||||
.lib-pill { font-family: var(--font-family-display); font-size: var(--font-size-small); cursor: pointer;
|
||||
padding: 0.28em 0.85em; border-radius: 999px;
|
||||
background: none; border: 1px solid var(--color-border); color: var(--color-text-muted); }
|
||||
.lib-pill:hover { border-color: var(--section-color, var(--accent)); color: var(--color-text-primary); }
|
||||
.lib-pill.active { background: var(--section-color, var(--accent)); border-color: var(--section-color, var(--accent)); color: white; }
|
||||
|
||||
/* Library-Atlas: zwei Kategorien nebeneinander */
|
||||
.atlas--grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-md); align-items: start; }
|
||||
@media (max-width: 600px) { .atlas--grid2 { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ── Software-Landing: Werkzeuge getrennt von Texten ── */
|
||||
.software-h { font-family: var(--font-family-serif); margin: var(--spacing-md) 0 var(--spacing-sm); }
|
||||
.software-tools { margin-bottom: var(--spacing-lg); }
|
||||
.tool-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.8em; }
|
||||
.tool-item { display: flex; align-items: flex-start; gap: 0.5em; background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border); border-left: 4px solid var(--section-color, var(--accent)); border-radius: 12px; padding: 0.9em 1em; }
|
||||
.tool-item:hover { border-color: var(--section-color, var(--accent)); }
|
||||
.tool-main { flex: 1; text-decoration: none; color: inherit; display: flex; flex-direction: column; gap: 0.2em; min-width: 0; }
|
||||
.tool-name { font-family: var(--font-family-serif); font-weight: 600; font-size: 1.05rem; }
|
||||
.tool-item:hover .tool-name { color: var(--accent); }
|
||||
.tool-sum { font-size: var(--font-size-small); }
|
||||
.tool-ext { flex: none; color: var(--color-text-muted); text-decoration: none; font-size: 1.15em; line-height: 1; }
|
||||
.tool-ext:hover { color: var(--accent); }
|
||||
|
||||
/* ------------------------------------------------------------------------
|
||||
Journal entries — three Republik-style layouts (set in front matter
|
||||
via `layout: image|icon|text`). Every entry is a full-bleed coloured
|
||||
@@ -998,8 +1069,8 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
||||
.atlas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
margin-top: var(--spacing-md);
|
||||
gap: var(--spacing-md);
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
.atlas-section h2,
|
||||
.atlas-tags h2 {
|
||||
@@ -1009,7 +1080,7 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
||||
letter-spacing: -0.018em;
|
||||
color: var(--color-text-primary);
|
||||
border-bottom: 3px solid var(--section-color, var(--accent));
|
||||
padding-bottom: var(--spacing-xs);
|
||||
padding-bottom: 0.03em;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
.atlas-section h2 a,
|
||||
@@ -1064,10 +1135,10 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
||||
}
|
||||
.section-title {
|
||||
font-family: var(--font-family-serif);
|
||||
font-size: clamp(1.9rem, 4vw, 2.6rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.026em;
|
||||
line-height: 1.05;
|
||||
font-size: clamp(1.5rem, 2.6vw, 1.9rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.012em;
|
||||
line-height: 1.1;
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
.section-description {
|
||||
@@ -1077,9 +1148,46 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
/* Übersicht (Archiv/Library): wie eine Artikelseite — Inhalt in der Lesespalte
|
||||
(≈55ch, zentriert), gleicher Oberabstand wie .single, Titel mit Linie darunter
|
||||
in der Sektionsfarbe (Archiv grün, Library rot). */
|
||||
.collection { margin-top: var(--spacing-sm); }
|
||||
.collection-title,
|
||||
.collection-inner { max-width: 48.5rem; margin-inline: auto; }
|
||||
.collection .section-rubric { max-width: 48.5rem; margin-inline: auto; margin-top: var(--spacing-sm); }
|
||||
.collection-title {
|
||||
text-align: left;
|
||||
font-family: var(--font-family-serif);
|
||||
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 1.12;
|
||||
color: var(--color-text-primary);
|
||||
border-bottom: 3px solid var(--section-color, var(--accent));
|
||||
padding-bottom: 0.03em;
|
||||
margin: 0 0 var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Archiv-Umschalter (Kategorie ↔ Jahr) */
|
||||
.archiv-toggle { display: inline-flex; gap: 0.3em; margin: 0 0 0.5em; }
|
||||
.archiv-toggle button {
|
||||
font-family: var(--font-family-display); font-size: var(--font-size-small); cursor: pointer;
|
||||
padding: 0.3em 0.95em; border-radius: 999px;
|
||||
background: none; border: 1px solid var(--color-border); color: var(--color-text-muted);
|
||||
}
|
||||
.archiv-toggle button:hover { border-color: var(--section-color, var(--accent)); color: var(--color-text-primary); }
|
||||
.archiv-toggle button.is-active {
|
||||
background: var(--section-color, var(--accent));
|
||||
border-color: var(--section-color, var(--accent));
|
||||
color: white; font-weight: 600;
|
||||
}
|
||||
.archiv-view[hidden] { display: none; }
|
||||
|
||||
.time-list ul {
|
||||
list-style: none;
|
||||
margin-left: 0;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.list-item {
|
||||
border-top: 1px solid var(--color-border);
|
||||
@@ -1112,6 +1220,9 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
||||
font-style: italic;
|
||||
line-height: 1.45;
|
||||
}
|
||||
/* Im Grid: Datum unter dem Titel, keine Trennlinie zwischen Cards */
|
||||
.time-list .list-title-row { flex-direction: column; align-items: flex-start; gap: 0.2em; }
|
||||
.time-list .list-item, .time-list .list-item:last-child { border-top: none; border-bottom: none; }
|
||||
|
||||
/* ------------------------------------------------------------------------
|
||||
Software showcase
|
||||
@@ -1183,17 +1294,17 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
||||
}
|
||||
.single-header h1 {
|
||||
font-family: var(--font-family-serif);
|
||||
font-size: clamp(2.1rem, 4.6vw, 3rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.028em;
|
||||
line-height: 1.05;
|
||||
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 1.12;
|
||||
margin: 0 0 var(--spacing-sm);
|
||||
}
|
||||
.single-summary {
|
||||
font-family: var(--font-family-serif);
|
||||
font-style: normal;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.4;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 var(--spacing-md); /* breathing room before byline */
|
||||
max-width: 55ch;
|
||||
@@ -1557,14 +1668,17 @@ img:hover { filter: grayscale(0%); }
|
||||
.cite-fmt.is-active { color: var(--accent); font-weight: 600; }
|
||||
.cite-status { color: var(--color-text-muted); }
|
||||
|
||||
/* Aktionsreihe: Dialog links, Tags ganz rechts; darüber eine Trennlinie. */
|
||||
/* Aktionsreihe: Dialog links, Tags ganz rechts; darüber eine Trennlinie.
|
||||
margin-top = spacing-sm, damit der Abstand „zitieren → Trennlinie" gleich
|
||||
gross ist wie „obere Trennlinie → Quellen" (footnotes padding-top) — der
|
||||
Quellen-Block sitzt so symmetrisch zwischen den beiden Linien. */
|
||||
.article-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8em 1em;
|
||||
margin-top: var(--spacing-md);
|
||||
margin-top: var(--spacing-sm);
|
||||
padding-top: var(--spacing-sm);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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);
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../supabase.js';
|
||||
import { requireAdmin, roleOf } from '../auth.js';
|
||||
|
||||
// Autoren-/Nutzerverwaltung über die GoTrue-Admin-API (Service-Key). Nur Admins.
|
||||
const ADMINS = (process.env.ADMIN_EMAILS || '')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
|
||||
const users = new Hono();
|
||||
users.use('*', requireAdmin);
|
||||
|
||||
users.get('/', async (c) => {
|
||||
const { data, error } = await supabase.auth.admin.listUsers();
|
||||
if (error) return c.json({ error: error.message }, 500);
|
||||
const list = (data?.users || []).map((u) => {
|
||||
const role = roleOf(u);
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
created_at: u.created_at,
|
||||
role,
|
||||
isAdmin: role === 'admin',
|
||||
// Admins aus der .env lassen sich nicht per UI herabstufen.
|
||||
fixedAdmin: ADMINS.includes((u.email || '').toLowerCase()),
|
||||
};
|
||||
});
|
||||
return c.json(list);
|
||||
});
|
||||
|
||||
users.post('/', async (c) => {
|
||||
const { email, password } = await c.req.json();
|
||||
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
||||
const { data, error } = await supabase.auth.admin.createUser({ email, password, email_confirm: true });
|
||||
if (error) return c.json({ error: error.message }, 400);
|
||||
return c.json({ ok: true, id: data.user.id });
|
||||
});
|
||||
|
||||
users.put('/:id', async (c) => {
|
||||
const { password, role } = await c.req.json();
|
||||
const patch = {};
|
||||
if (password) patch.password = password;
|
||||
if (role) {
|
||||
if (!['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
|
||||
patch.app_metadata = { role };
|
||||
}
|
||||
if (!Object.keys(patch).length) return c.json({ error: 'Nichts zu ändern' }, 400);
|
||||
const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), patch);
|
||||
if (error) return c.json({ error: error.message }, 400);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
users.delete('/:id', async (c) => {
|
||||
const { error } = await supabase.auth.admin.deleteUser(c.req.param('id'));
|
||||
if (error) return c.json({ error: error.message }, 400);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export default users;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const url = process.env.SUPABASE_URL;
|
||||
const key = process.env.SUPABASE_SERVICE_KEY;
|
||||
|
||||
if (!url || !key) {
|
||||
console.error('FEHLT: SUPABASE_URL und/oder SUPABASE_SERVICE_KEY in .env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const opts = { auth: { persistSession: false, autoRefreshToken: false } };
|
||||
|
||||
// Daten-Client: Service-Role-Key, umgeht RLS. NUR für DB-Zugriffe (from/insert/…).
|
||||
// Wichtig: hier niemals signInWithPassword aufrufen — das schaltet den
|
||||
// Authorization-Header des Clients prozessweit auf das User-Token um (SIGNED_IN),
|
||||
// wodurch anschließende Inserts als role=authenticated laufen und an RLS scheitern.
|
||||
export const supabase = createClient(url, key, opts);
|
||||
|
||||
// Eigener Client nur für Auth (Login, Token-Prüfung). Getrennt, damit ein
|
||||
// signInWithPassword den Daten-Client oben nicht „vergiftet". Niemals ins Frontend.
|
||||
export const supabaseAuth = createClient(url, key, opts);
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# build output
|
||||
admin/dist/
|
||||
@@ -0,0 +1,202 @@
|
||||
# openbureau-core — handover
|
||||
|
||||
## For a fresh instance — START HERE
|
||||
- **Repo (local):** cloned at `/home/karim/openbureau-core`. Remote
|
||||
`git.openbureau.ch/karim/openbureau-core` is **private**; auth via an HTTPS token
|
||||
in `~/.git-credentials` (the SSH key `id_ed25519_openbureau` is currently rejected
|
||||
by gitea/proxmox — server seems rebuilt; fix the key or keep using the token).
|
||||
- **Where the work is:** branch **`stufe-2-5-scaffold`** (pushed, NOT merged). `main`
|
||||
is still the pre-stage-2 foundation. Open a PR or merge when happy.
|
||||
- **Done:** stages 1–4 complete, stage 5 *scaffolded*. See "Migration stages" below
|
||||
(markers `[done]`/`[scaffold]`). New code: `api/src/collections.js`,
|
||||
`api/src/plugin-manager.js`, `api/src/plugins/dialog/index.js` + their tests.
|
||||
- **Run tests:** `cd api && npm install && node --test` → **44 green**. `node_modules`
|
||||
is git-ignored; the fresh clone needs `npm install` once.
|
||||
- **Status:** stages 1–9 DONE; openbureau + kgva BOTH LIVE on core. openbureau on dev CT134 (schema editor + supabase); kgva LIVE at karimgabrielevarano.xyz via core (Variant A: CMS serves the site; CT130 rebuilt in-place — Docker + kgva-cms on :8081, nginx :80 proxies to it and keeps /media, static timer disabled, Caddy untouched). CAVEAT: kgva GIT_PUBLISH=false → edits on CT130 disk, not git-backed (enable GIT_PUBLISH + creds to fix). Rollback for kgva: restore nginx .static.bak + re-enable kgva-deploy.timer + stop kgva-cms. Original stage-5 note kept below for history.
|
||||
- **Immediate next action (historic):** stage 5 + 5.5 **DONE** on the branch (commits `c4230a4`
|
||||
cutover, `5e06337` DDL, `a93d11a` migration runner). Plugin-manager singleton
|
||||
(`api/src/plugins.js`) wired into `index.js`/`stats.js`/`publish.js`, hard-coded
|
||||
dialog removed, source moved under `api/src/plugins/dialog/`; DDL captured into
|
||||
`plugins/dialog/migrations/001_dialog.sql` (idempotent); `api/src/migrate.js`
|
||||
applies migrations once via `schema_migrations` at boot. **53 tests green** + a real
|
||||
boot. **Live-verified on the dev stack** (CT 134): a 2nd cms instance from
|
||||
`cms-cms:latest` (my `src` mounted over `/app/src`, real Supabase via `kong:8000`,
|
||||
empty SITE_DIR → no writes) returned **byte-identical** `/api/forums` & `/api/recent`
|
||||
vs the running container, `/api/content` 401 on both; the runner's tracking SQL was
|
||||
proven on a throwaway DB. **NOT deployed** (the running stack still runs old code).
|
||||
NEXT (stage 8): deploy core to openbureau — needs `DATABASE_URL` set for the cms
|
||||
service (e.g. `postgres://postgres:$POSTGRES_PASSWORD@db:5432/postgres`) so the
|
||||
runner can apply migrations, then full login/edit/publish verify before cutting the
|
||||
live container over. Then stages 6 (admin `/api/schema`), 7 (local auth), 9 (kgva).
|
||||
Dev stack: Proxmox CT 134 `openbureau-dev`, repo `/opt/openbureau`, compose
|
||||
`cms/docker-compose.yml`, containers openbureau-{cms,auth,kong,rest,db}, dev URL
|
||||
dev.openbureau.ch. **Do NOT push a blind refactor to prod.**
|
||||
- **Env gotcha:** editing a file that's open in Karim's VSCode makes the Edit/Write
|
||||
tool hang/"interrupt" — write such files via Bash (`cat > f <<'EOF' … EOF`) instead.
|
||||
|
||||
## Goal
|
||||
Extract a generic, **schema-driven** Hugo CMS engine ("openbureau-core") from the
|
||||
openbureau site's bundled CMS, so that **both** openbureau and
|
||||
karimgabrielevarano.xyz (kgva) consume it as a dependency. Decision: own repo
|
||||
(this one), and migrate openbureau to depend on it.
|
||||
|
||||
## State (done this session)
|
||||
- Repo `karim/openbureau-core` created on Gitea, foundation pushed.
|
||||
- `api/` = engine copied verbatim from `karim/OPENBUREAU` → `cms/api` (Hono/Node).
|
||||
- `admin/` = React/Vite SPA copied verbatim from `OPENBUREAU/cms/admin`.
|
||||
- `api/src/config.js` = NEW loader: reads `CMS_CONFIG` → a site config module.
|
||||
- `examples/openbureau.config.js`, `examples/kgva.config.js` = target schemas.
|
||||
- `README.md` = config API + plugin model + the migration checklist.
|
||||
- Nothing in the live openbureau CMS was changed yet.
|
||||
|
||||
## Engine is ~80% generic. openbureau-specific seams to make config-driven:
|
||||
1. [DONE] `api/src/files.js` `classify(rel)` + the `order` map — now derived from
|
||||
`config.collections` via `api/src/collections.js` (classify/buildPath/compareEntries);
|
||||
`buildPath` exposed there too.
|
||||
2. [DONE] `api/src/routes/stats.js` — now per-collection (`statKey`/`draftStatKey`),
|
||||
dialog counts gated by `hasPlugin('dialog')`.
|
||||
3. `api/src/index.js` — always mounts dialog routes + runs `syncLibrary` on boot.
|
||||
→ only when `config.plugins` includes `dialog`.
|
||||
4. `api/src/routes/publish.js` — calls `syncLibrary`. → gate by plugin.
|
||||
5. `admin/src/App.jsx` (714 lines) — hard-codes `SECTIONS`, `KIND_LABEL`, the field
|
||||
set, type dropdown, `buildPath`. → render sidebar groups + editor fields from the
|
||||
schema (add `GET /api/schema` returning `config.collections`).
|
||||
6. dialog subsystem (`dialog-store.js`, `routes/dialog.js`, `routes/comments.js`
|
||||
+ Supabase tables forums/threads/comments + library↔thread sync) = the `dialog`
|
||||
**plugin** (openbureau on, kgva off). First gate by config, later move to
|
||||
`api/src/plugins/dialog/`.
|
||||
|
||||
## CRITICAL
|
||||
api and admin share a data contract (stats keys, field shapes). Generalising the
|
||||
**backend alone breaks the live openbureau admin** — they must change together and
|
||||
be tested on the live Supabase/Hugo stack. Do NOT push a blind half-refactor to
|
||||
production openbureau.
|
||||
|
||||
## Plugin system (decided architecture)
|
||||
Everything beyond the generic engine is a **plugin**; the engine gets a small
|
||||
**plugin manager**. A plugin = `api/src/plugins/<name>/index.js` exporting a
|
||||
manifest `{ name, routes:[{path,app,public?,admin?}], onBoot, onPublish, onPreview,
|
||||
migrations:[…], stats, admin:{…} }`. The manager reads `config.plugins`, imports
|
||||
each, mounts routes in the right auth tier (public before `requireAuth`, rest
|
||||
after, `admin:true` behind `requireAdmin`), registers boot/publish/preview/stats
|
||||
hooks, runs migrations; the admin SPA loads UI from `admin/src/plugins/<name>`.
|
||||
openbureau's extras become plugins (first: `dialog`); kgva enables none.
|
||||
(Full spec in README → "Plugins".)
|
||||
|
||||
## Migration stages (also README checklist)
|
||||
1. [done] repo + engine + config loader + example configs.
|
||||
2. [done] files.js classify/order/buildPath ← config.collections
|
||||
(new `api/src/collections.js` = pure classify/buildPath/compareEntries/contentStats;
|
||||
files.js uses it, loads config lazily in listEntries; `api/test/collections.test.js`
|
||||
proves 1:1 vs openbureau.config.js). All api tests green.
|
||||
3. [done] stats.js ← collections: `content` via `statKey` + `draftStatKey`
|
||||
(openbureau beitrag got `draftStatKey: 'entwuerfe'`), dialog counts gated by
|
||||
`hasPlugin('dialog')` (pluginless site does zero DB reads here).
|
||||
4. [built, not wired] **plugin manager** — `api/src/plugin-manager.js`
|
||||
(`loadPlugins(names)` imports `plugins/<name>/index.js`; `createManager(manifests)`
|
||||
= pure wiring: `mountPublic`/`mountPrivate(+requireAdmin)`, `runBoot/runPublish/
|
||||
runPreview`, `collectStats` (merged under plugin name), `migrations()` (resolves
|
||||
file URLs; execution deferred to the live DB)). Unit-tested in
|
||||
`api/test/plugin-manager.test.js` (tiers/hooks/stats/migrations). TODO: wire into
|
||||
index.js (stage 5), and admin loads plugin UI from `admin/src/plugins/<name>`.
|
||||
5. [DONE — commits c4230a4 (cutover) + 5e06337 (DDL), live e2e verified] extract openbureau extras
|
||||
into the **dialog** plugin. DONE: `api/src/plugins/dialog/index.js` = manifest
|
||||
wrapping the existing modules unchanged (public reads + widget login(rate-limited)
|
||||
+ authed writes + self-guarding mod/adminForums sub-apps; onBoot/onPublish =
|
||||
syncLibrary; stats = forum/thread/comment counts). CUTOVER DONE: shared manager
|
||||
singleton `api/src/plugins.js` wired into index.js (mountPublic before requireAuth,
|
||||
mountPrivate(+requireAdmin) after, manager.runBoot at serve()), publish.js
|
||||
(manager.runPublish) and stats.js (manager.collectStats, `{content,users,dialog}`
|
||||
contract preserved); hard-coded dialog REMOVED from all three; dialog source
|
||||
physically moved to `plugins/dialog/{dialog-store,dialog,comments}.js` (imports
|
||||
fixed). Tested: `api/test/{dialog-plugin,cutover}.test.js`, 48 green + real boot.
|
||||
DDL CAPTURED (commit 5e06337): `migrations/001_dialog.sql` from the live dev DB,
|
||||
idempotent, validated on a throwaway DB; manifest `migrations: ['001_dialog.sql']`.
|
||||
LIVE E2E VERIFIED (dev CT 134): 2nd cms instance from cms-cms:latest with my src
|
||||
mounted, real Supabase via kong:8000 — /api/forums & /api/recent byte-identical to
|
||||
the running container, /api/content 401 both, no writes. Migration runner = stage
|
||||
5.5 (commit a93d11a, see new item below). Deploy is stage 8.
|
||||
NB: dialog-store.js still filters `e.kind === 'beitrag'` — openbureau-specific,
|
||||
already moved with the plugin.
|
||||
5.5 [done, commit a93d11a] migration runner — `api/src/migrate.js` `runMigrations`
|
||||
tracks applied migrations in `public.schema_migrations` and applies each declared
|
||||
file once, in its own transaction, at boot (wired into index.js before runBoot).
|
||||
Lean: no declared migrations → no DB connection, no pg import (kgva stays DB-less).
|
||||
Executor is injectable (unit-tested without a DB); default uses a lazily-imported
|
||||
`pg` against `DATABASE_URL`, warning-and-skipping if unset. Needs `DATABASE_URL`
|
||||
wired into the cms service env for openbureau (stage 8). Tested: migrate.test.js +
|
||||
tracking SQL proven on the live dev Postgres (throwaway DB).
|
||||
6. [DONE, commits f709b5d + f8f4131 + 59b9e20] schema-driven admin. `GET /api/schema`
|
||||
+ `GET /api/system` + a System panel; AND the editor now renders sidebar groups +
|
||||
fields from the site's collections (admin/src/fields.jsx: per-type controls
|
||||
string/text/slug/number/date/bool/select/list/image/color/markdown; form↔frontmatter
|
||||
+ path building). One admin serves any site. Unknown frontmatter is PRESERVED on
|
||||
save (__raw) — no data loss. Verified working via kgva (stage 9). NOTE: openbureau
|
||||
is NOT yet redeployed with the schema editor (its running image keeps the bespoke
|
||||
one); when it is, its config field types (color→swatch, layout opts) are already
|
||||
tuned. Visual review of the openbureau editor by Karim still recommended before
|
||||
redeploying it.
|
||||
7. [DONE, commit 86f9f57] **auth provider** (`config.auth` = `supabase` | `local`).
|
||||
`api/src/auth-local.js` = file-based users (bcryptjs) + self-signed HS256 JWTs of
|
||||
the SAME claim shape; `routes/auth.js` POST /api/auth/login (mounted only when
|
||||
auth:local); supabase.js made null-safe (fail-fast moved to index.js); auth/stats/
|
||||
users.js branch local↔GoTrue; admin supabase.js gains a local-auth SHIM (same
|
||||
interface) when VITE_SUPABASE_URL is absent → App/api untouched, supabase path
|
||||
byte-identical. examples/kgva.config.js → auth:'local'. Verified: 59 tests +
|
||||
DB-less boot smoke (no Supabase: local login → /api/me admin → stats-from-file)
|
||||
+ openbureau redeployed and confirmed unregressed (auth=supabase).
|
||||
8. [DONE, OB main 3d902a0] openbureau consumes core. Mechanism: **git subtree** at
|
||||
`OPENBUREAU/cms/core` (fits the pull-based deploy; update via
|
||||
`git subtree pull --prefix=cms/core <core> main --squash`). docker-compose builds
|
||||
the cms image from `./core`; `cms/openbureau.config.js` (CMS_CONFIG) carries the
|
||||
content model; old vendored cms/api+cms/admin removed. DATABASE_URL left unset (the
|
||||
stack's migrate service owns db/schema.sql incl. dialog). Deployed to dev CT 134
|
||||
and FULL write-verified: login/list/edit/preview/publish/dialog all identical, no
|
||||
git divergence (GIT_PUBLISH=false). Rollback image `cms-cms:rollback` kept.
|
||||
9. [DONE — LIVE on core (Variant A)] kgva consumes core. In
|
||||
`karim/kgva` (git.kgva.ch, main 51a94b8): `cms/core` = core subtree, `cms/kgva.config.js`
|
||||
(project/page/section, auth:'local', no plugins), `docker-compose.yml` builds the core
|
||||
image (Hugo 0.163.3, no VITE_SUPABASE_URL → local-auth admin), `cms/seed-admin.mjs`,
|
||||
updated deploy/provision-lxc.sh + ADMIN.md. VERIFIED DB-less end-to-end on real content
|
||||
(staged on CT134): local login → schema editor → 42 entries (de/en) → stats →
|
||||
create/preview/cleanup; image galleries preserved. The LIVE site is UNCHANGED — CT130
|
||||
still deploys statically (timer → `hugo` → nginx; hugo ignores cms/); the CMS is dormant.
|
||||
PENDING (deliberate, his call — repoints a live domain): provision a kgva-core container
|
||||
(deploy/provision-lxc.sh, free CTID + temp IP) and decide topology — CMS serves the site
|
||||
(Caddy → it) OR CMS edits + GIT_PUBLISH=true commits to git and the CT130 static timer
|
||||
keeps serving. Then Caddy cutover per deploy/README. openbureau also not yet redeployed
|
||||
with the schema editor (stage 6 note).
|
||||
|
||||
## How to run / test
|
||||
openbureau CMS stack: `OPENBUREAU/cms/docker-compose.yml` (Node api + Hugo +
|
||||
Supabase: postgres/kong/gotrue). Env in `cms/.env` (see `.env.example`):
|
||||
SUPABASE_URL/SERVICE_KEY, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY (derive via
|
||||
`scripts/generate-keys.mjs`), ADMIN_EMAILS, SITE_URL, GIT_*. For core add
|
||||
`CMS_CONFIG` (path to the site config) and `SITE_DIR` (site repo mount, default
|
||||
`/site`). Admin at `/admin`, preview `/_preview`, publish builds `public/`.
|
||||
|
||||
## Infra access (NO secrets in this file)
|
||||
- Proxmox node `192.168.1.2`, user `root`. Password: **ask the user** (provided ad
|
||||
hoc, not stored). No SSH key installed — use SSH_ASKPASS, or install a key first.
|
||||
- Gitea = container 120 (`pct exec 120 …`), ROOT_URL `git.openbureau.ch`, sqlite at
|
||||
`/var/lib/gitea/data/gitea.db`. Make a token:
|
||||
`pct exec 120 -- su gitea -s /bin/bash -c "/usr/local/bin/gitea admin user
|
||||
generate-access-token --username karim --scopes all --token-name X --raw
|
||||
--config /etc/gitea/app.ini"`. Keep token `kgva-deploy`. Delete tokens via sqlite
|
||||
(`DELETE FROM access_token WHERE name LIKE '…'`) since the API needs the account
|
||||
password.
|
||||
- Push without local git auth: tar → scp to node → `pct push 120` into the gitea
|
||||
container → `git push http://karim:<token>@127.0.0.1:3000/karim/<repo>.git`.
|
||||
- kgva site deploy: container 130 (kgva-website). nginx serves
|
||||
`/var/www/karimgabrielevarano.xyz` (= ZFS `tank/kgva-website`, owned uid 100000).
|
||||
systemd timer runs `/opt/kgva-deploy.sh` every 60s: pull `karim/kgva` (token
|
||||
`kgva-deploy`) → `hugo` build → copy into webroot. So a push to `karim/kgva` is
|
||||
live in ~1 min. Self-hosted video at `/var/www/media` (nginx `/media/` alias,
|
||||
outside the build). Public TLS for the domain terminates upstream → 192.168.1.130:80.
|
||||
|
||||
## Watch out
|
||||
- Gitea does NOT send CORS on its OAuth token endpoint → a browser git-CMS
|
||||
(Decap-style) can't auth without a same-origin proxy. openbureau-core uses its own
|
||||
Supabase auth, so this doesn't affect it; it's why kgva edits go via git push.
|
||||
- Hugo versions: openbureau CMS bundles 0.161.1; the kgva site needs 0.163.3
|
||||
features. Mind the version a core instance builds with.
|
||||
@@ -0,0 +1,149 @@
|
||||
# openbureau-core
|
||||
|
||||
A generic, schema-driven Hugo CMS engine, extracted from the openbureau site.
|
||||
A site provides one config (collections + plugins); the core gives it an admin,
|
||||
content CRUD over `content/**/*.md` (gray-matter), real Hugo preview/publish,
|
||||
Supabase auth, uploads and git backup. openbureau and karimgabrielevarano.xyz
|
||||
both consume it.
|
||||
|
||||
## How a site uses it
|
||||
|
||||
1. Point `CMS_CONFIG` at a config module (e.g. `/site/cms.config.js`).
|
||||
2. Mount the site repo at `SITE_DIR` (default `/site`). Run the container.
|
||||
|
||||
```
|
||||
SITE_DIR=/site
|
||||
CMS_CONFIG=/site/cms.config.js
|
||||
SUPABASE_URL=… SUPABASE_SERVICE_KEY=… JWT_SECRET=… ADMIN_EMAILS=…
|
||||
```
|
||||
|
||||
## Config API
|
||||
|
||||
```js
|
||||
export default {
|
||||
site: 'name',
|
||||
admins: ['you@example.com'], // bootstrap admins (env ADMIN_EMAILS still wins)
|
||||
auth: 'supabase', // 'supabase' (GoTrue) | 'local' (file JWTs, no DB)
|
||||
plugins: ['dialog'], // optional features (see Plugins)
|
||||
collections: [
|
||||
{
|
||||
kind: 'project', // internal id
|
||||
label: 'Portfolio', // shown in the admin
|
||||
order: 0, // sort order in the list
|
||||
path: 'portfolio/:slug', // file pattern under content/ (:params captured)
|
||||
// index: true, // matches _index.md (a section)
|
||||
// fallback: true, // matches anything not matched above
|
||||
// sections: [...], // fixed choices for a :section param
|
||||
statKey: 'projects', // key in the stats response
|
||||
fields: [ // editor form, in order
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'images', type: 'list', of: { src: 'image', name: 'string' } },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
`path` patterns: literal segments must match; `:param` segments are captured
|
||||
(the last one is the slug). `index: true` → `_index.md`. `fallback: true` →
|
||||
everything else. The engine derives content classification, the `buildPath`, the
|
||||
list ordering, the stats counters and the admin editor from this.
|
||||
|
||||
Field `type`s: `string · text · markdown · slug · date · number · bool · select
|
||||
(options) · image · list · list(of:{…})`.
|
||||
|
||||
## Plugins
|
||||
|
||||
Everything beyond the generic engine is a **plugin**. A site opts into plugins via
|
||||
`plugins: [...]` in its config; core's plugin manager loads them and wires them in.
|
||||
The engine stays lean; openbureau = core + its plugins + its config.
|
||||
|
||||
A plugin is a module at `api/src/plugins/<name>/index.js` exporting a manifest:
|
||||
|
||||
```js
|
||||
export default {
|
||||
name: 'dialog',
|
||||
routes: [ // mounted under /api
|
||||
{ path: '/forums', app: forums, public: true }, // skips requireAuth
|
||||
{ path: '/comments', app: comments }, // auth required
|
||||
{ path: '/admin/forums', app: adminForums, admin: true }, // admins only
|
||||
],
|
||||
onBoot: async (ctx) => {}, // run once at startup (e.g. syncLibrary)
|
||||
onPublish: async (ctx, { path }) => {}, // hook after a publish build
|
||||
onPreview: async (ctx, { path }) => {}, // hook after a preview build
|
||||
migrations: ['001_forums.sql'], // applied to Postgres/Supabase on boot
|
||||
stats: async (ctx) => ({ forums, threads, comments }), // merged into /api/stats
|
||||
admin: { /* UI panels the admin SPA mounts for this plugin */ },
|
||||
};
|
||||
```
|
||||
|
||||
The **plugin manager** (core): reads `config.plugins`, imports each module, mounts
|
||||
its routes in the right auth tier (public → before `requireAuth`, the rest after,
|
||||
`admin: true` behind `requireAdmin`), registers its boot/publish/preview/stats
|
||||
hooks, and runs its migrations. The admin SPA loads UI from
|
||||
`admin/src/plugins/<name>` for enabled plugins only.
|
||||
|
||||
First plugin: **`dialog`** — the comment/forum subsystem (Supabase
|
||||
`forums/threads/comments`, library↔thread sync, forum admin UI). openbureau enables
|
||||
it; kgva doesn't, so none of its routes, hooks, tables or stats load there.
|
||||
|
||||
## Auth providers
|
||||
|
||||
Auth is a config seam (`auth: 'supabase' | 'local'`, default `supabase`) so a site
|
||||
can run **without a database**. The engine already verifies tokens locally — HS256
|
||||
against `JWT_SECRET`, no GoTrue roundtrip ([api/src/auth.js]) — so a provider only
|
||||
has to *issue* tokens and *store users*.
|
||||
|
||||
- **`supabase`** (today): GoTrue logs the user in and is the user store; the engine
|
||||
verifies the JWT. Needed when there are multiple editors or the `dialog` plugin
|
||||
(its forums/threads/comments live in Postgres). This is the heavy path.
|
||||
- **`local`**: file-based users (bcrypt hashes) + self-signed HS256 JWTs of the
|
||||
**same claim shape** (`sub`, `email`, `app_metadata.role`, `exp`), so `requireAuth`
|
||||
and roles are unchanged. No GoTrue, no Postgres, no PostgREST. A dialog-less site
|
||||
(e.g. kgva, single admin) then runs as just **Node + Hugo + nginx** (~80 MB vs the
|
||||
~1–2 GB Supabase suite). Trade-off: you own password hashing; you lose GoTrue's
|
||||
password-reset / rate-limiting — fine for a single-admin CMS.
|
||||
|
||||
What a pluginless core needs from Supabase is *only* the auth half anyway: content
|
||||
is Markdown on disk, uploads are local FS (`static/images`, via sharp), and
|
||||
PostgREST/tables are a `dialog`-plugin need. `local` drops that half too.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `api/` — Node engine (Hono): `auth` (Supabase JWT), `files` (gray-matter
|
||||
CRUD), `hugo` (coalesced build / preview / publish + git), `routes/*`,
|
||||
`ratelimit`, `config` (this loader).
|
||||
- `admin/` — React/Vite SPA; renders list + editor from the config schema.
|
||||
- `examples/` — `openbureau.config.js`, `kgva.config.js` (the two consumers).
|
||||
|
||||
## Status / migration stages
|
||||
|
||||
This repo currently holds the **engine as extracted** plus the config API and the
|
||||
two target configs. Generalisation is staged because the api and admin share a
|
||||
data contract and openbureau is in production — they must change together and be
|
||||
tested on the live Supabase/Hugo stack, not flipped blind.
|
||||
|
||||
- [x] Repo, engine, config loader, example configs (openbureau + kgva)
|
||||
- [x] `files.js` classify / order / buildPath driven by `collections`
|
||||
(`api/src/collections.js` + tests; behaviour 1:1 vs openbureau.config.js)
|
||||
- [x] `stats.js` counters driven by `collections` (`statKey` / `draftStatKey`),
|
||||
dialog counts gated by `hasPlugin('dialog')`
|
||||
- [~] **plugin manager** built + unit-tested (`api/src/plugin-manager.js`,
|
||||
`api/test/plugin-manager.test.js`): loads `config.plugins`, mounts routes by
|
||||
auth tier, runs boot/publish/preview hooks, merges stats, surfaces migrations.
|
||||
NOT yet wired into `index.js` (lands with stage 5, needs the live stack);
|
||||
admin plugin-UI loading still TODO
|
||||
- [~] extract openbureau's extras into plugins — first **`dialog`**: manifest
|
||||
scaffold at `api/src/plugins/dialog/index.js` wraps the existing dialog
|
||||
modules (routes by tier, `syncLibrary` on boot/publish, forum counters as
|
||||
`stats`), loaded + structurally tested (`api/test/dialog-plugin.test.js`).
|
||||
TODO (live-tested): wire it into `index.js`, capture the Supabase DDL into
|
||||
`migrations/001_dialog.sql`, move the dialog source under `plugins/dialog/`
|
||||
- [ ] `index.js` / `publish.js`: no hard-coded dialog — everything via the manager
|
||||
- [ ] admin: editor + sidebar groups rendered from the config schema
|
||||
- [ ] **auth provider** (`supabase` | `local`): `local` = file users + self-signed
|
||||
HS256 JWTs (same claim shape), no DB — lets a dialog-less core run Supabase-free
|
||||
- [ ] cut openbureau over to consume core (= core + `dialog` plugin + its config),
|
||||
behaviour identical — tested on the live stack
|
||||
- [ ] onboard kgva as the second consumer (`auth: 'local'`, no plugins, DB-less)
|
||||
@@ -3,33 +3,10 @@ import ToastEditor from '@toast-ui/editor';
|
||||
import '@toast-ui/editor/dist/toastui-editor.css';
|
||||
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 KIND_LABEL = { beitrag: 'Beiträge', seite: 'Seiten', rubrik: 'Rubriken' };
|
||||
|
||||
const EMPTY = {
|
||||
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: '', authors: '', toc: false, draft: true, body: '',
|
||||
};
|
||||
import {
|
||||
Field, blankForm, formFromFrontmatter, frontmatterFromForm, targetPath,
|
||||
isShort, isBody, hexOf, DEFAULT_PALETTE,
|
||||
} from './fields.jsx';
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState(null);
|
||||
@@ -69,6 +46,9 @@ function Login() {
|
||||
|
||||
function Dashboard({ email }) {
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [collections, setCollections] = useState(null);
|
||||
const [plugins, setPlugins] = useState([]);
|
||||
const [site, setSite] = useState('');
|
||||
const [current, setCurrent] = useState(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [view, setView] = useState('content');
|
||||
@@ -79,30 +59,57 @@ function Dashboard({ email }) {
|
||||
try { setEntries(await api.list()); }
|
||||
catch (e) { setMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
useEffect(() => { refresh(); api.getMe().then(setMe).catch(() => {}); }, []);
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
api.getMe().then(setMe).catch(() => {});
|
||||
api.schema().then((sc) => {
|
||||
setCollections((sc.collections || []).slice().sort((a, b) => (a.order ?? 99) - (b.order ?? 99)));
|
||||
setPlugins(sc.plugins || []); setSite(sc.site || '');
|
||||
}).catch(() => setCollections([]));
|
||||
}, []);
|
||||
useEffect(() => { if (!msg) return; const t = setTimeout(() => setMsg(null), 4000); return () => clearTimeout(t); }, [msg]);
|
||||
|
||||
const cols = collections || [];
|
||||
const hasDialog = plugins.includes('dialog');
|
||||
const collectionOf = (kind) => cols.find((c) => c.kind === kind) || cols.find((c) => c.fallback) || cols[0] || null;
|
||||
|
||||
async function open(entry) {
|
||||
try { setCurrent(fromRead(await api.read(entry.path))); }
|
||||
catch (err) { setMsg({ type: 'err', text: err.message }); }
|
||||
const col = collectionOf(entry.kind);
|
||||
try {
|
||||
const r = await api.read(entry.path);
|
||||
const bodyField = (col?.fields || []).find(isBody);
|
||||
setCurrent({
|
||||
isNew: false, kind: entry.kind, path: entry.path,
|
||||
...formFromFrontmatter(r.frontmatter || {}, col || { fields: [] }),
|
||||
...(bodyField ? { [bodyField.name]: r.body || '' } : {}),
|
||||
});
|
||||
} catch (err) { setMsg({ type: 'err', text: err.message }); }
|
||||
}
|
||||
function openNew() {
|
||||
const col = cols[0];
|
||||
if (col) setCurrent({ isNew: true, kind: col.kind, path: '', ...blankForm(col) });
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = q ? entries.filter((e) => e.title.toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
|
||||
const groups = { beitrag: [], seite: [], rubrik: [] };
|
||||
for (const e of filtered) (groups[e.kind] || groups.seite).push(e);
|
||||
const filtered = q ? entries.filter((e) => (e.title || '').toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
|
||||
const fallbackKind = (cols.find((c) => c.fallback) || cols[0] || {}).kind;
|
||||
const groups = {};
|
||||
for (const c of cols) groups[c.kind] = [];
|
||||
for (const e of filtered) { const k = groups[e.kind] ? e.kind : fallbackKind; if (groups[k]) groups[k].push(e); }
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<span className="logo">OPENBUREAU</span>
|
||||
<span className="logo">{(site || 'cms').toUpperCase()}</span>
|
||||
<span className="logo-sub">Redaktion</span>
|
||||
<nav className="nav">
|
||||
{me?.isAdmin && <button className={view === 'overview' ? 'active' : ''} onClick={() => setView('overview')}>Übersicht</button>}
|
||||
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
|
||||
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
|
||||
{me?.canModerate && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
|
||||
{me?.isAdmin && <button className={view === 'forums' ? 'active' : ''} onClick={() => setView('forums')}>Foren</button>}
|
||||
{me?.canModerate && hasDialog && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
|
||||
{me?.isAdmin && hasDialog && <button className={view === 'forums' ? 'active' : ''} onClick={() => setView('forums')}>Foren</button>}
|
||||
{me?.isAdmin && <button className={view === 'users' ? 'active' : ''} onClick={() => setView('users')}>Autor:innen</button>}
|
||||
{me?.isAdmin && <button className={view === 'system' ? 'active' : ''} onClick={() => setView('system')}>System</button>}
|
||||
</nav>
|
||||
<span className="spacer" />
|
||||
<span className="who">{email}</span>
|
||||
@@ -110,7 +117,9 @@ function Dashboard({ email }) {
|
||||
</header>
|
||||
|
||||
<div className="body">
|
||||
{view === 'profile' ? (
|
||||
{view === 'overview' ? (
|
||||
<Overview onMsg={setMsg} go={setView} />
|
||||
) : view === 'profile' ? (
|
||||
<Profile onMsg={setMsg} />
|
||||
) : view === 'users' ? (
|
||||
<Users onMsg={setMsg} currentEmail={me?.email} />
|
||||
@@ -118,18 +127,20 @@ function Dashboard({ email }) {
|
||||
<Forums onMsg={setMsg} />
|
||||
) : view === 'moderation' ? (
|
||||
<Moderation onMsg={setMsg} />
|
||||
) : view === 'system' ? (
|
||||
<System onMsg={setMsg} />
|
||||
) : (
|
||||
<>
|
||||
<aside>
|
||||
<button className="new" onClick={() => setCurrent({ ...EMPTY })}>+ Neuer Beitrag</button>
|
||||
<button className="new" onClick={openNew} disabled={!cols.length}>+ Neu</button>
|
||||
<div className="search"><span>⌕</span><input placeholder="Suchen…" value={query} onChange={(e) => setQuery(e.target.value)} /></div>
|
||||
{['beitrag', 'seite', 'rubrik'].map((kind) => groups[kind].length > 0 && (
|
||||
<div className="group" key={kind}>
|
||||
<div className="group-title">{KIND_LABEL[kind]} <span>{groups[kind].length}</span></div>
|
||||
{cols.map((c) => groups[c.kind]?.length > 0 && (
|
||||
<div className="group" key={c.kind}>
|
||||
<div className="group-title">{c.label} <span>{groups[c.kind].length}</span></div>
|
||||
<ul className="list">
|
||||
{groups[kind].map((e) => (
|
||||
{groups[c.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="dot" style={{ background: e.color ? hexOf(DEFAULT_PALETTE, e.color) : 'var(--line)' }} />
|
||||
<span className="t">
|
||||
<span className="t-title">{e.title}</span>
|
||||
<span className="t-meta">{[e.section, e.date].filter(Boolean).join(' · ')}</span>
|
||||
@@ -143,9 +154,9 @@ function Dashboard({ email }) {
|
||||
</aside>
|
||||
<main>
|
||||
{current
|
||||
? <Editor key={current.path || 'new'} initial={current}
|
||||
? <Editor key={(current.path || 'new') + ':' + current.kind} initial={current} collections={cols}
|
||||
onSaved={(loaded) => { setCurrent(loaded); refresh(); }} onMsg={setMsg} />
|
||||
: <div className="empty"><p>Wähle links einen Eintrag — oder leg einen neuen Beitrag an.</p></div>}
|
||||
: <div className="empty"><p>Wähle links einen Eintrag — oder leg einen neuen an.</p></div>}
|
||||
</main>
|
||||
</>
|
||||
)}
|
||||
@@ -156,7 +167,7 @@ function Dashboard({ email }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Editor({ initial, onSaved, onMsg }) {
|
||||
function Editor({ initial, collections, onSaved, onMsg }) {
|
||||
const [f, setF] = useState(initial);
|
||||
const [previewUrl, setPreviewUrl] = useState(null);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
@@ -164,16 +175,23 @@ function Editor({ initial, onSaved, onMsg }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const editorRef = useRef(null);
|
||||
const dragging = useRef(false);
|
||||
const coverIn = useRef(null);
|
||||
const set = (k) => (e) => setF({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value });
|
||||
|
||||
async function pickCover(ev) {
|
||||
const file = ev.target.files?.[0]; ev.target.value = '';
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
try { const { url } = await api.upload(file); setF((p) => ({ ...p, cover_image: url })); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
finally { setBusy(false); }
|
||||
const cols = collections || [];
|
||||
const collection = cols.find((c) => c.kind === f.kind) || cols[0] || { fields: [] };
|
||||
const fields = collection.fields || [];
|
||||
const titleField = fields.find((x) => x.name === 'title') || fields.find((x) => x.type === 'string' && x.required) || null;
|
||||
const bodyField = fields.find(isBody);
|
||||
const draftField = fields.find((x) => x.type === 'bool' && x.name === 'draft');
|
||||
const shortFields = fields.filter((x) => x !== titleField && !isBody(x) && isShort(x));
|
||||
const longFields = fields.filter((x) => x !== titleField && !isBody(x) && !isShort(x));
|
||||
const isDraft = draftField ? !!f[draftField.name] : false;
|
||||
const hasSlugField = fields.some((x) => x.name === 'slug');
|
||||
|
||||
const setField = (name, val) => setF((p) => ({ ...p, [name]: val }));
|
||||
const upload = async (file) => (await api.upload(file)).url;
|
||||
function changeKind(kind) {
|
||||
const col = cols.find((c) => c.kind === kind);
|
||||
if (col) setF({ isNew: true, kind, path: '', ...blankForm(col) });
|
||||
}
|
||||
|
||||
// Ziehbarer Trenner Editor ↔ Vorschau.
|
||||
@@ -190,23 +208,22 @@ function Editor({ initial, onSaved, onMsg }) {
|
||||
}, []);
|
||||
function startDrag(e) { dragging.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }
|
||||
|
||||
function currentPath(data = f) {
|
||||
if (!data.isNew) return data.path;
|
||||
const slug = (data.slug || '').trim();
|
||||
if (!slug) return '';
|
||||
return data.type === 'beitrag' ? `library/${data.section}/${slug}.md` : `${slug}.md`;
|
||||
}
|
||||
|
||||
// overrides erlauben z.B. { draft: false } beim Publizieren.
|
||||
async function save(overrides = {}) {
|
||||
const data = { ...f, ...overrides };
|
||||
const path = currentPath(data);
|
||||
if (!path) { onMsg({ type: 'err', text: 'Bitte einen Slug angeben.' }); return null; }
|
||||
if (!data.title.trim()) { onMsg({ type: 'err', text: 'Titel fehlt.' }); return null; }
|
||||
const path = data.isNew ? targetPath(data, collection) : data.path;
|
||||
if (!path) { onMsg({ type: 'err', text: 'Bitte einen Slug (und ggf. Pflichtsegmente wie Rubrik) angeben.' }); return null; }
|
||||
if (titleField && !((data[titleField.name] || '').trim())) { onMsg({ type: 'err', text: 'Titel fehlt.' }); return null; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.save(path, buildFrontmatter(data), data.body);
|
||||
const loaded = fromRead(await api.read(path));
|
||||
const fm = frontmatterFromForm(data, collection);
|
||||
const body = bodyField ? (data[bodyField.name] || '') : '';
|
||||
await api.save(path, fm, body);
|
||||
const r = await api.read(path);
|
||||
const loaded = {
|
||||
isNew: false, kind: data.kind, path,
|
||||
...formFromFrontmatter(r.frontmatter || {}, collection),
|
||||
...(bodyField ? { [bodyField.name]: r.body || '' } : {}),
|
||||
};
|
||||
onSaved(loaded); setF(loaded);
|
||||
return path;
|
||||
} catch (e) { onMsg({ type: 'err', text: e.message }); return null; }
|
||||
@@ -220,8 +237,8 @@ function Editor({ initial, onSaved, onMsg }) {
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function publish() {
|
||||
if (!confirm('Live publizieren? Der Beitrag wird aus „Entwurf“ genommen.')) return;
|
||||
const path = await save({ draft: false }); // Publizieren = nicht mehr Entwurf
|
||||
if (!confirm('Live publizieren?')) return;
|
||||
const path = await save(draftField ? { [draftField.name]: false } : {});
|
||||
if (!path) return;
|
||||
setBusy(true);
|
||||
try { const res = await api.publish(path); onMsg({ type: 'ok', text: `Live: ${res.url}` }); }
|
||||
@@ -235,7 +252,7 @@ function Editor({ initial, onSaved, onMsg }) {
|
||||
<div className="editor-head">
|
||||
<div className="crumb">{f.isNew ? 'Neuer Eintrag' : f.path}</div>
|
||||
<span className="spacer" />
|
||||
{f.draft ? <span className="status draft">Entwurf</span> : <span className="status live">Veröffentlicht</span>}
|
||||
{draftField && (isDraft ? <span className="status draft">Entwurf</span> : <span className="status live">Veröffentlicht</span>)}
|
||||
<button className="toggle" onClick={() => setShowPreview((v) => !v)} title="Vorschau ein/aus">
|
||||
{showPreview ? 'Vorschau ⤫' : 'Vorschau ⤢'}
|
||||
</button>
|
||||
@@ -248,59 +265,37 @@ function Editor({ initial, onSaved, onMsg }) {
|
||||
{f.isNew && (
|
||||
<div className="row">
|
||||
<label className="sm">Typ
|
||||
<select value={f.type} onChange={set('type')}>
|
||||
<option value="beitrag">Beitrag</option>
|
||||
<option value="seite">Seite</option>
|
||||
<select value={f.kind} onChange={(e) => changeKind(e.target.value)}>
|
||||
{cols.map((c) => <option key={c.kind} value={c.kind}>{c.label}</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>
|
||||
{!hasSlugField && (
|
||||
<label className="sm">Dateiname
|
||||
<input value={f.slug || ''} onChange={(e) => setField('slug', e.target.value)} placeholder="z. B. impressum" />
|
||||
</label>
|
||||
)}
|
||||
<label>Slug<input value={f.slug} onChange={set('slug')} placeholder="z.B. neuer-beitrag" /></label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="big">Titel<input value={f.title} onChange={set('title')} placeholder="Titel des Beitrags" /></label>
|
||||
|
||||
<div className="meta">
|
||||
<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 className="sm">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>
|
||||
{titleField && (
|
||||
<label className="big">{titleField.label || 'Titel'}
|
||||
<input value={f[titleField.name] || ''} onChange={(e) => setField(titleField.name, e.target.value)} placeholder="Titel" />
|
||||
</label>
|
||||
<label className="sm">Layout
|
||||
<select value={f.layout} onChange={set('layout')}>{LAYOUTS.map((l) => <option key={l} value={l}>{l || '(automatisch)'}</option>)}</select>
|
||||
</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')} /> Inhaltsverz.</label>
|
||||
<label className="check"><input type="checkbox" checked={f.draft} onChange={set('draft')} /> Entwurf</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label>Kurztext (summary)<input value={f.summary} onChange={set('summary')} /></label>
|
||||
<div className="row">
|
||||
<label>Cover-Bild
|
||||
<div className="cover-row">
|
||||
<input value={f.cover_image} onChange={set('cover_image')} placeholder="/images/…jpg" />
|
||||
<button type="button" onClick={() => coverIn.current?.click()} disabled={busy}>Hochladen</button>
|
||||
<input ref={coverIn} type="file" accept="image/*" hidden onChange={pickCover} />
|
||||
{f.cover_image && <span className="cover-thumb" style={{ backgroundImage: `url(${f.cover_image})` }} />}
|
||||
</div>
|
||||
</label>
|
||||
<label>Externer Link<input value={f.external} onChange={set('external')} placeholder="https://…" /></label>
|
||||
</div>
|
||||
<label>Autor:innen (E-Mails, Komma — für gemeinsamen Zugriff)
|
||||
<input value={f.authors} onChange={set('authors')} placeholder="du@…, kollege@…" />
|
||||
</label>
|
||||
{shortFields.length > 0 && (
|
||||
<div className="meta">
|
||||
{shortFields.map((fld) => <Field key={fld.name} field={fld} value={f[fld.name]} onChange={setField} onUpload={upload} busy={busy} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rich">
|
||||
<RichEditor value={f.body} onChange={(body) => setF((p) => ({ ...p, body }))}
|
||||
onUpload={async (file) => (await api.upload(file)).url} />
|
||||
</div>
|
||||
{longFields.map((fld) => <Field key={fld.name} field={fld} value={f[fld.name]} onChange={setField} onUpload={upload} busy={busy} />)}
|
||||
|
||||
{bodyField && (
|
||||
<div className="rich">
|
||||
<RichEditor value={f[bodyField.name] || ''} onChange={(body) => setField(bodyField.name, body)} onUpload={upload} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -394,6 +389,112 @@ function Profile({ onMsg }) {
|
||||
<label>Über mich<textarea value={p.bio} onChange={set('bio')} rows={5} placeholder="Kurzer Text über dich…" /></label>
|
||||
<div className="actions"><button className="primary" onClick={save} disabled={busy}>Speichern</button></div>
|
||||
</div>
|
||||
<PasswordChange onMsg={onMsg} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordChange({ onMsg }) {
|
||||
const [cur, setCur] = useState('');
|
||||
const [nxt, setNxt] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
if (nxt.length < 6) { onMsg({ type: 'err', text: 'Neues Passwort zu kurz (min. 6 Zeichen).' }); return; }
|
||||
setBusy(true);
|
||||
try { await api.changePassword(cur, nxt); onMsg({ type: 'ok', text: 'Passwort geändert.' }); setCur(''); setNxt(''); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return (
|
||||
<div className="profile-card">
|
||||
<h2>Passwort ändern</h2>
|
||||
<form onSubmit={submit}>
|
||||
<label>Aktuelles Passwort<input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" /></label>
|
||||
<label>Neues Passwort<input type="password" value={nxt} onChange={(e) => setNxt(e.target.value)} autoComplete="new-password" /></label>
|
||||
<div className="actions"><button className="primary" disabled={busy}>Passwort setzen</button></div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Übersicht / Dashboard (nur Admin) ───────────────────────────────────────
|
||||
function Overview({ onMsg, go }) {
|
||||
const [s, setS] = useState(null);
|
||||
useEffect(() => { api.stats().then(setS).catch((e) => onMsg({ type: 'err', text: e.message })); }, []);
|
||||
if (!s) return <div className="empty">…</div>;
|
||||
const Card = ({ label, value, hint, to }) => (
|
||||
<button className="stat-card" onClick={to ? () => go(to) : undefined} disabled={!to}>
|
||||
<span className="stat-value">{value ?? 0}</span>
|
||||
<span className="stat-label">{label}</span>
|
||||
<span className="stat-hint">{hint || ' '}</span>
|
||||
</button>
|
||||
);
|
||||
const content = s.content || {};
|
||||
const d = s.dialog || { forums: 0, threads: 0, comments: 0 };
|
||||
const hasDialog = (d.forums + d.threads + d.comments) > 0;
|
||||
return (
|
||||
<div className="overview">
|
||||
<h2>Übersicht</h2>
|
||||
<div className="stat-grid">
|
||||
{Object.entries(content).map(([k, v]) => <Card key={k} label={k} value={v} to="content" />)}
|
||||
<Card label="Autor:innen" value={s.users?.total} hint={`${s.users?.admin || 0} Admin · ${s.users?.editor || 0} Red.`} to="users" />
|
||||
{hasDialog && <Card label="Foren" value={d.forums} to="forums" />}
|
||||
{hasDialog && <Card label="Threads" value={d.threads} to="moderation" />}
|
||||
{hasDialog && <Card label="Wortmeldungen" value={d.comments} to="moderation" />}
|
||||
</div>
|
||||
<div className="overview-actions">
|
||||
<h3>Schnellzugriff</h3>
|
||||
<div className="quick">
|
||||
<button onClick={() => go('content')}>Inhalte bearbeiten</button>
|
||||
{hasDialog && <button onClick={() => go('forums')}>Foren verwalten</button>}
|
||||
<button onClick={() => go('users')}>Autor:innen & Rollen</button>
|
||||
<a className="quick-link" href="/" target="_blank" rel="noreferrer">Website ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── System (nur Admin): core-Version, Update-Check, Admins, Plugins, Modell ──
|
||||
function System({ onMsg }) {
|
||||
const [s, setS] = useState(null);
|
||||
const [upd, setUpd] = useState(null);
|
||||
useEffect(() => {
|
||||
api.system().then(setS).catch((e) => onMsg({ type: 'err', text: e.message }));
|
||||
api.systemUpdate().then(setUpd).catch(() => {});
|
||||
}, []);
|
||||
if (!s) return <div className="empty">…</div>;
|
||||
const badge = !upd ? null
|
||||
: upd.available ? <span className="status draft">core v{upd.vendored} bereit — Rebuild nötig</span>
|
||||
: upd.vendored ? <span className="status live">aktuell</span>
|
||||
: <span className="muted">(kein Subtree)</span>;
|
||||
return (
|
||||
<div className="profile">
|
||||
<div className="profile-card wide">
|
||||
<h2>System</h2>
|
||||
<div className="sysgrid">
|
||||
<div><span className="muted">Core</span><b>{s.core.name} <span className="ver">v{s.core.version}</span></b> {badge}</div>
|
||||
<div><span className="muted">Site</span><b>{s.site || '—'}</b></div>
|
||||
<div><span className="muted">Auth</span><b>{s.auth}</b></div>
|
||||
<div><span className="muted">Hugo</span><b>{s.hugo}</b></div>
|
||||
</div>
|
||||
<h3>Admins <span className="count-pill">{(s.admins || []).length}</span></h3>
|
||||
<ul className="syslist">{(s.admins || []).map((a) => (
|
||||
<li key={a}><b>{a}</b><span className="muted">aus Config (fix)</span></li>
|
||||
))}</ul>
|
||||
<h3>Plugins <span className="count-pill">{s.plugins.length}</span></h3>
|
||||
{s.plugins.length
|
||||
? <ul className="syslist">{s.plugins.map((p) => (
|
||||
<li key={p.name}><b>{p.name}</b><span className="muted">{p.routes} Routen · {p.migrations} Migration(en)</span></li>
|
||||
))}</ul>
|
||||
: <p className="muted">Keine Plugins aktiv.</p>}
|
||||
<h3>Content-Modell <span className="count-pill">{s.collections.length}</span></h3>
|
||||
<ul className="syslist">{s.collections.map((c) => (
|
||||
<li key={c.kind}><b>{c.label}</b><span className="muted">{c.kind} · {c.fields} Felder{c.statKey ? ` · ${c.statKey}` : ''}</span></li>
|
||||
))}</ul>
|
||||
<p className="muted who-mail">openbureau-core fährt diese Site. Updates werden serverseitig eingespielt (Update-Skript).</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -403,7 +504,11 @@ function Users({ onMsg, currentEmail }) {
|
||||
const [list, setList] = useState(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState('user');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [q, setQ] = useState('');
|
||||
const [pwFor, setPwFor] = useState(null);
|
||||
const [newPw, setNewPw] = useState('');
|
||||
|
||||
async function refresh() {
|
||||
try { setList(await api.listUsers()); }
|
||||
@@ -413,59 +518,85 @@ function Users({ onMsg, currentEmail }) {
|
||||
|
||||
async function create(e) {
|
||||
e.preventDefault(); setBusy(true);
|
||||
try { await api.createUser(email, password); onMsg({ type: 'ok', text: 'Autor:in angelegt.' }); setEmail(''); setPassword(''); refresh(); }
|
||||
catch (err) { onMsg({ type: 'err', text: err.message }); }
|
||||
try {
|
||||
await api.createUser(email, password, role);
|
||||
onMsg({ type: 'ok', text: 'Autor:in angelegt.' });
|
||||
setEmail(''); setPassword(''); setRole('user'); refresh();
|
||||
} catch (err) { onMsg({ type: 'err', text: err.message }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function remove(u) {
|
||||
if (!confirm(`${u.email} löschen?`)) return;
|
||||
if (!confirm(`${u.email} wirklich löschen?`)) return;
|
||||
try { await api.deleteUser(u.id); refresh(); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
async function reset(u) {
|
||||
const pw = prompt(`Neues Passwort für ${u.email}:`);
|
||||
if (!pw) return;
|
||||
try { await api.setPassword(u.id, pw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); }
|
||||
async function savePw(u) {
|
||||
if (!newPw || newPw.length < 6) { onMsg({ type: 'err', text: 'Passwort zu kurz (min. 6 Zeichen).' }); return; }
|
||||
try { await api.setPassword(u.id, newPw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); setPwFor(null); setNewPw(''); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
async function changeRole(u, role) {
|
||||
try { await api.setRole(u.id, role); onMsg({ type: 'ok', text: `Rolle: ${ROLE_LABEL[role]}` }); refresh(); }
|
||||
async function changeRole(u, r) {
|
||||
try { await api.setRole(u.id, r); onMsg({ type: 'ok', text: `Rolle: ${ROLE_LABEL[r]}` }); refresh(); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
|
||||
if (!list) return <div className="empty">…</div>;
|
||||
const filtered = q ? list.filter((u) => u.email.toLowerCase().includes(q.toLowerCase())) : list;
|
||||
const RoleSelect = ({ u }) => (
|
||||
<select className="role-select" value={u.role} onChange={(e) => changeRole(u, e.target.value)}>
|
||||
<option value="user">User</option><option value="editor">Redakteur</option><option value="admin">Admin</option>
|
||||
</select>
|
||||
);
|
||||
return (
|
||||
<div className="profile">
|
||||
<div className="profile-card">
|
||||
<h2>Autor:innen & Rollen</h2>
|
||||
<div className="profile-card wide">
|
||||
<h2>Autor:innen & Rollen <span className="count-pill">{list.length}</span></h2>
|
||||
<form className="userform" onSubmit={create}>
|
||||
<input type="email" placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
<input type="text" placeholder="Passwort" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
<select className="role-select" value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
<option value="user">User</option><option value="editor">Redakteur</option><option value="admin">Admin</option>
|
||||
</select>
|
||||
<button className="primary" disabled={busy}>Anlegen</button>
|
||||
</form>
|
||||
{list.length > 6 && <input className="userfilter" placeholder="filtern…" value={q} onChange={(e) => setQ(e.target.value)} />}
|
||||
<ul className="userlist">
|
||||
{list.map((u) => (
|
||||
{filtered.map((u) => (
|
||||
<li key={u.id}>
|
||||
<span className="t">{u.email}</span>
|
||||
{u.fixedAdmin
|
||||
? <span className="status live">Admin (.env)</span>
|
||||
: <select className="role-select" value={u.role} onChange={(e) => changeRole(u, e.target.value)}>
|
||||
<option value="user">User</option>
|
||||
<option value="editor">Redakteur</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>}
|
||||
<button onClick={() => reset(u)}>Passwort</button>
|
||||
<span className="uavatar" style={avatarStyle(u.email)}>{(u.email || '?').slice(0, 1).toUpperCase()}</span>
|
||||
<span className="t ucol">
|
||||
<span className="uemail">{u.email}{u.email === currentEmail && <span className="you"> · du</span>}</span>
|
||||
<span className="umeta">
|
||||
angelegt {fmtDate(u.created_at)}
|
||||
{u.last_sign_in_at ? ` · zuletzt aktiv ${fmtDate(u.last_sign_in_at)}` : ' · nie angemeldet'}
|
||||
</span>
|
||||
</span>
|
||||
{u.fixedAdmin ? <span className="rolebadge admin">Admin · .env</span> : <RoleSelect u={u} />}
|
||||
{pwFor === u.id ? (
|
||||
<span className="pwinline">
|
||||
<input type="text" placeholder="neues Passwort" value={newPw} autoFocus
|
||||
onChange={(e) => setNewPw(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') savePw(u); if (e.key === 'Escape') { setPwFor(null); setNewPw(''); } }} />
|
||||
<button onClick={() => savePw(u)}>OK</button>
|
||||
<button onClick={() => { setPwFor(null); setNewPw(''); }}>✕</button>
|
||||
</span>
|
||||
) : (
|
||||
<button onClick={() => { setPwFor(u.id); setNewPw(''); }}>Passwort</button>
|
||||
)}
|
||||
{u.email !== currentEmail && !u.fixedAdmin && <button onClick={() => remove(u)}>Löschen</button>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="muted who-mail"><b>User</b> schreiben nur im Forum · <b>Redakteur</b> moderiert · <b>Admin</b> verwaltet alles. Admins aus <code>ADMIN_EMAILS</code> sind fix.</p>
|
||||
<p className="muted who-mail"><b>User</b> schreiben im Forum · <b>Redakteur</b> moderiert · <b>Admin</b> verwaltet alles. Admins aus <code>ADMIN_EMAILS</code> sind fix.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ROLE_LABEL = { user: 'User', editor: 'Redakteur', admin: 'Admin' };
|
||||
function fmtDate(ts) { if (!ts) return '—'; try { return new Date(ts).toLocaleDateString('de-CH'); } catch { return '—'; } }
|
||||
function uHashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return Math.abs(h) % 360; }
|
||||
function avatarStyle(s) { const h = uHashHue(s); return { background: `hsl(${h} 36% 82%)`, color: `hsl(${h} 30% 28%)` }; }
|
||||
|
||||
// ── Foren-Verwaltung (nur Admin) ────────────────────────────────────────────
|
||||
function Forums({ onMsg }) {
|
||||
@@ -599,36 +730,3 @@ function slugify(s) {
|
||||
return (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
// ── Mapping Datei-Lesart → Formular ────────────────────────────────────────
|
||||
function fromRead(r) {
|
||||
const fm = r.frontmatter || {};
|
||||
return {
|
||||
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 || '',
|
||||
authors: Array.isArray(fm.authors) ? fm.authors.join(', ') : (fm.authors || ''),
|
||||
toc: !!fm.toc, draft: !!fm.draft, body: r.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;
|
||||
const authors = f.authors ? f.authors.split(',').map((t) => t.trim()).filter(Boolean) : [];
|
||||
if (authors.length) fm.authors = authors;
|
||||
if (f.toc) fm.toc = true;
|
||||
if (f.draft) fm.draft = true;
|
||||
return fm;
|
||||
}
|
||||
@@ -44,8 +44,13 @@ export const api = {
|
||||
getProfile: () => call('/profile'),
|
||||
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
|
||||
getMe: () => call('/me'),
|
||||
stats: () => call('/stats'),
|
||||
system: () => call('/system'),
|
||||
systemUpdate: () => call('/system/update'),
|
||||
schema: () => call('/schema'),
|
||||
changePassword: (current, next) => call('/account/password', { method: 'POST', body: JSON.stringify({ current, next }) }),
|
||||
listUsers: () => call('/users'),
|
||||
createUser: (email, password) => call('/users', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||
createUser: (email, password, role) => call('/users', { method: 'POST', body: JSON.stringify({ email, password, role }) }),
|
||||
setPassword: (id, password) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ password }) }),
|
||||
setRole: (id, role) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
||||
deleteUser: (id) => call(`/users/${id}`, { method: 'DELETE' }),
|
||||
@@ -0,0 +1,154 @@
|
||||
// Schema-driven form fields. The editor renders a collection's `fields` (from
|
||||
// /api/schema) generically, so the SAME admin works for any site's content model
|
||||
// (openbureau, kgva, …). Field types → controls; markdown ('body') is handled by
|
||||
// the rich editor in App.jsx, not here.
|
||||
//
|
||||
// A field: { name, type, required?, options?, default?, hint?, palette? }
|
||||
// Types: string | text | slug | number | date | bool | select | list | image |
|
||||
// color | markdown (markdown rendered separately).
|
||||
|
||||
// openbureau's named colour palette — used for type:'color' when no per-field
|
||||
// palette is given. Harmless elsewhere (sites without colour fields never see it).
|
||||
export const DEFAULT_PALETTE = [
|
||||
['', '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'],
|
||||
];
|
||||
export const hexOf = (palette, name) => (palette.find((c) => c[0] === name) || [])[2] || 'transparent';
|
||||
|
||||
const SHORT = new Set(['string', 'slug', 'number', 'date', 'bool', 'select', 'color']);
|
||||
export const isShort = (f) => SHORT.has(f.type);
|
||||
export const isBody = (f) => f.type === 'markdown';
|
||||
|
||||
// Empty form for a new entry of `collection`.
|
||||
export function blankForm(collection) {
|
||||
const f = { __raw: {} };
|
||||
for (const fld of collection.fields || []) {
|
||||
if (isBody(fld)) { f[fld.name] = fld.default ?? ''; continue; }
|
||||
if (fld.type === 'bool') f[fld.name] = fld.default ?? false;
|
||||
else if (fld.type === 'list') f[fld.name] = '';
|
||||
else f[fld.name] = fld.default ?? '';
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
// Frontmatter (from disk) → form values, per field type. The full original
|
||||
// frontmatter is stashed under __raw so unknown keys survive a save (no data loss).
|
||||
export function formFromFrontmatter(fm, collection) {
|
||||
const f = { __raw: fm || {} };
|
||||
for (const fld of collection.fields || []) {
|
||||
const v = fm[fld.name];
|
||||
if (fld.type === 'bool') f[fld.name] = !!v;
|
||||
else if (fld.type === 'list') f[fld.name] = Array.isArray(v) ? v.join(', ') : (v || '');
|
||||
else if (fld.type === 'date') f[fld.name] = v ? String(v).slice(0, 10) : '';
|
||||
else if (fld.type === 'number') f[fld.name] = v ?? '';
|
||||
else f[fld.name] = v ?? '';
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
// Form values → frontmatter. Starts from any preserved __raw (so fields not in the
|
||||
// schema, e.g. image galleries, are kept), then sets/clears the schema fields.
|
||||
export function frontmatterFromForm(form, collection) {
|
||||
const fm = { ...(form.__raw || {}) };
|
||||
const setOrDrop = (k, v) => { if (v === '' || v == null || (Array.isArray(v) && !v.length)) delete fm[k]; else fm[k] = v; };
|
||||
for (const fld of collection.fields || []) {
|
||||
const v = form[fld.name];
|
||||
if (fld.type === 'bool') { if (v) fm[fld.name] = true; else delete fm[fld.name]; continue; }
|
||||
if (fld.type === 'number') { setOrDrop(fld.name, v === '' || v == null ? '' : Number(v)); continue; }
|
||||
if (fld.type === 'list') { setOrDrop(fld.name, (v || '').split(',').map((x) => x.trim()).filter(Boolean)); continue; }
|
||||
setOrDrop(fld.name, v);
|
||||
}
|
||||
return fm;
|
||||
}
|
||||
|
||||
// Build the target path from collection.path ( e.g. 'archiv/:section/:slug' ),
|
||||
// falling back to '<slug>.md' (or '<id>.md' when there is no slug field).
|
||||
export function targetPath(form, collection) {
|
||||
const tmpl = collection.path;
|
||||
const slug = (form.slug || '').trim();
|
||||
if (tmpl) {
|
||||
const rel = tmpl.replace(/:([a-z_]+)/gi, (_, k) => (form[k] || '').toString().trim());
|
||||
if (rel.includes('//') || rel.endsWith('/') || /:/.test(rel)) return ''; // missing segment
|
||||
return rel + '.md';
|
||||
}
|
||||
if (slug) return `${slug}.md`;
|
||||
return '';
|
||||
}
|
||||
|
||||
// One field control (everything except markdown/body).
|
||||
export function Field({ field, value, onChange, onUpload, busy }) {
|
||||
const set = (val) => onChange(field.name, val);
|
||||
const label = field.label || field.name;
|
||||
const palette = field.palette || DEFAULT_PALETTE;
|
||||
|
||||
if (field.type === 'bool') {
|
||||
return <label className="check"><input type="checkbox" checked={!!value} onChange={(e) => set(e.target.checked)} /> {label}</label>;
|
||||
}
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<label className="sm">{label}
|
||||
<select value={value || ''} onChange={(e) => set(e.target.value)}>
|
||||
{!field.required && <option value="">(—)</option>}
|
||||
{(field.options || []).map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (field.type === 'color') {
|
||||
return (
|
||||
<label className="sm">{label}
|
||||
<div className="colorpick">
|
||||
<span className="swatch" style={{ background: hexOf(palette, value) }} />
|
||||
<select value={value || ''} onChange={(e) => set(e.target.value)}>
|
||||
{palette.map(([v, lbl]) => <option key={v} value={v}>{lbl}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (field.type === 'image') {
|
||||
return (
|
||||
<label>{label}
|
||||
<div className="cover-row">
|
||||
<input value={value || ''} onChange={(e) => set(e.target.value)} placeholder="/img/…" />
|
||||
<ImageUpload onUpload={onUpload} onDone={set} busy={busy} />
|
||||
{value && <span className="cover-thumb" style={{ backgroundImage: `url(${value})` }} />}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (field.type === 'text') {
|
||||
return <label>{label}<textarea value={value || ''} rows={2} onChange={(e) => set(e.target.value)} placeholder={field.hint || ''} /></label>;
|
||||
}
|
||||
if (field.type === 'number') {
|
||||
return <label className="xs">{label}<input type="number" value={value ?? ''} onChange={(e) => set(e.target.value)} placeholder={field.hint || ''} /></label>;
|
||||
}
|
||||
if (field.type === 'date') {
|
||||
return <label className="sm">{label}<input type="date" value={value || ''} onChange={(e) => set(e.target.value)} /></label>;
|
||||
}
|
||||
if (field.type === 'list') {
|
||||
return <label>{label}<input value={value || ''} onChange={(e) => set(e.target.value)} placeholder={field.hint || 'komma, getrennt'} /></label>;
|
||||
}
|
||||
// string, slug, and any unknown type → text input
|
||||
const cls = field.type === 'slug' ? 'sm' : '';
|
||||
return <label className={cls}>{label}<input value={value || ''} onChange={(e) => set(e.target.value)} placeholder={field.hint || ''} /></label>;
|
||||
}
|
||||
|
||||
import { useRef } from 'react';
|
||||
function ImageUpload({ onUpload, onDone, busy }) {
|
||||
const ref = useRef(null);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => ref.current?.click()} disabled={busy}>Hochladen</button>
|
||||
<input ref={ref} type="file" accept="image/*" hidden onChange={async (ev) => {
|
||||
const file = ev.target.files?.[0]; ev.target.value = '';
|
||||
if (!file) return;
|
||||
try { onDone(await onUpload(file)); } catch { /* ignore */ }
|
||||
}} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -184,7 +184,48 @@ label.big input { font-family: var(--serif); font-weight: 600; }
|
||||
.mod-actions a { color: var(--muted); }
|
||||
.mod-actions button { padding: 4px 11px; font-size: 12.5px; }
|
||||
|
||||
/* ── Übersicht / Dashboard ── */
|
||||
.overview { width: 100%; overflow: auto; padding: 30px 28px; }
|
||||
.overview h2 { font-family: var(--serif); font-weight: 600; margin: 0 0 18px; }
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
|
||||
.stat-card { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow); }
|
||||
.stat-card:not(:disabled):hover { border-color: var(--accent-soft); transform: translateY(-1px); }
|
||||
.stat-card:disabled { opacity: 1; cursor: default; }
|
||||
.stat-value { font-family: var(--display); font-weight: 700; font-size: 30px; line-height: 1; color: var(--accent); }
|
||||
.stat-label { font-family: var(--serif); font-size: 15px; margin-top: 6px; }
|
||||
.stat-hint { font-size: 11.5px; color: var(--muted); min-height: 1em; }
|
||||
.overview-actions { margin-top: 30px; }
|
||||
.overview-actions h3 { font-family: var(--display); font-size: 12px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin: 0 0 10px; }
|
||||
.quick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.quick-link { display: inline-flex; align-items: center; padding: 8px 16px; border: 1px solid var(--line); border-radius: var(--pill); text-decoration: none; color: var(--muted); }
|
||||
.quick-link:hover { border-color: var(--accent-soft); color: var(--text); }
|
||||
|
||||
/* ── Nutzerliste (aufgewertet) ── */
|
||||
.count-pill { font-family: var(--sans); font-size: 12px; font-weight: 500; color: var(--muted); background: var(--panel-2); border-radius: 20px; padding: 2px 9px; vertical-align: middle; margin-left: 6px; }
|
||||
.userfilter { margin: 4px 0 2px; height: 34px; }
|
||||
.userlist .uavatar { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-weight: 600; font-size: 13px; flex: none; }
|
||||
.userlist .ucol { flex-direction: column; align-items: flex-start; gap: 1px; min-width: 0; }
|
||||
.uemail { font-family: var(--serif); font-size: 14.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
|
||||
.uemail .you { color: var(--accent); font-family: var(--sans); font-size: 12px; }
|
||||
.umeta { font-size: 11.5px; color: var(--muted); }
|
||||
.rolebadge { font-size: 11px; border-radius: var(--pill); padding: 3px 10px; font-weight: 600; flex: none; }
|
||||
.rolebadge.admin { color: var(--accent); background: rgba(181,74,44,.12); }
|
||||
.pwinline { display: flex; align-items: center; gap: 5px; flex: none; }
|
||||
.pwinline input { width: 150px; height: 30px; }
|
||||
.pwinline button { padding: 4px 10px; font-size: 12.5px; }
|
||||
|
||||
/* ── Toast ── */
|
||||
.toast { position: fixed; bottom: 20px; right: 20px; padding: 11px 18px; border-radius: 11px; color: #fff; cursor: pointer; box-shadow: 0 10px 30px -12px rgba(0,0,0,.4); font-size: 13.5px; max-width: 380px; z-index: 50; }
|
||||
.toast.ok { background: var(--ok); }
|
||||
.toast.err { background: var(--accent); }
|
||||
|
||||
/* System-Panel */
|
||||
.sysgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: .75rem; margin: .5rem 0 1rem; }
|
||||
.sysgrid > div { display: flex; flex-direction: column; gap: .15rem; }
|
||||
.sysgrid .muted { font-size: .8rem; }
|
||||
.sysgrid b { font-size: 1.05rem; }
|
||||
.sysgrid .ver { opacity: .6; font-weight: 400; }
|
||||
.syslist { list-style: none; padding: 0; margin: .25rem 0 1rem; }
|
||||
.syslist li { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding: .4rem 0; border-bottom: 1px solid var(--line); }
|
||||
.syslist li .muted { font-size: .85rem; }
|
||||
@@ -0,0 +1,44 @@
|
||||
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;
|
||||
|
||||
// Mit Supabase-URL: echter Client (openbureau, unverändert). Ohne (DB-loser core,
|
||||
// auth: 'local', z. B. kgva): ein Shim mit DERSELBEN auth-Schnittstelle, die App/
|
||||
// api nutzen — getSession / onAuthStateChange / signInWithPassword / signOut —
|
||||
// gegen /api/auth/login, Token in localStorage. So bleibt der Supabase-Pfad
|
||||
// völlig unangetastet, und der lokale Pfad braucht keine Änderung an App/api.
|
||||
function localAuthClient() {
|
||||
const KEY = 'cms_local_session';
|
||||
const read = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } };
|
||||
let listeners = [];
|
||||
const emit = (s) => listeners.forEach((cb) => { try { cb(s ? 'SIGNED_IN' : 'SIGNED_OUT', s); } catch { /* ignore */ } });
|
||||
return {
|
||||
auth: {
|
||||
async getSession() { return { data: { session: read() } }; },
|
||||
onAuthStateChange(cb) {
|
||||
listeners.push(cb);
|
||||
return { data: { subscription: { unsubscribe() { listeners = listeners.filter((l) => l !== cb); } } } };
|
||||
},
|
||||
async signInWithPassword({ email, password }) {
|
||||
try {
|
||||
const r = await fetch('/api/auth/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) return { error: { message: j.error || `HTTP ${r.status}` } };
|
||||
const session = { access_token: j.access_token, user: { id: j.user.id, email: j.user.email } };
|
||||
localStorage.setItem(KEY, JSON.stringify(session));
|
||||
emit(session);
|
||||
return { data: { session }, error: null };
|
||||
} catch (e) { return { error: { message: String(e.message || e) } }; }
|
||||
},
|
||||
async signOut() { localStorage.removeItem(KEY); emit(null); return { error: null }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const supabase = url ? createClient(url, anonKey) : localAuthClient();
|
||||
+156
-2
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"name": "openbureau-cms-api",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openbureau-cms-api",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@supabase/supabase-js": "^2.47.10",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hono": "^4.6.14",
|
||||
"marked": "^14.1.4",
|
||||
"pg": "^8.22.0",
|
||||
"sharp": "^0.33.5"
|
||||
}
|
||||
},
|
||||
@@ -528,6 +530,12 @@
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
@@ -685,6 +693,134 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/section-matter": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||
@@ -758,6 +894,15 @@
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
@@ -778,6 +923,15 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openbureau-cms-api",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Headless CMS backend für OPENBUREAU — schreibt Supabase-Posts in Hugo-content/, baut und serviert die Site.",
|
||||
@@ -12,9 +12,11 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@supabase/supabase-js": "^2.47.10",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hono": "^4.6.14",
|
||||
"marked": "^14.1.4",
|
||||
"pg": "^8.22.0",
|
||||
"sharp": "^0.33.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Local auth provider — file-based users (bcrypt) + self-signed JWTs of the SAME
|
||||
// claim shape as Supabase/GoTrue (sub / email / app_metadata.role / exp), so the
|
||||
// rest of the engine (auth.js verifyToken, roleOf) treats local and supabase
|
||||
// tokens identically. Lets a core instance run DB-less (no Supabase/Postgres):
|
||||
// Node + Hugo + nginx. Activated by config.auth === 'local'.
|
||||
//
|
||||
// Users live in a JSON file (USERS_FILE, default <SITE_DIR>/cms-users.json):
|
||||
// [{ id, email, password: <bcrypt hash>, role, created_at, last_sign_in_at }]
|
||||
import path from 'node:path';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { sign } from 'hono/jwt';
|
||||
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
const USERS_FILE = process.env.USERS_FILE || path.join(SITE_DIR, 'cms-users.json');
|
||||
const JWT_SECRET = process.env.JWT_SECRET || '';
|
||||
const ROLES = ['user', 'editor', 'admin'];
|
||||
|
||||
// bcryptjs is pure-JS (no native build) and lazily imported — a supabase-auth
|
||||
// instance never loads it.
|
||||
async function bcrypt() { return (await import('bcryptjs')).default; }
|
||||
|
||||
export async function loadUsers() {
|
||||
try {
|
||||
const j = JSON.parse(await readFile(USERS_FILE, 'utf8'));
|
||||
return Array.isArray(j) ? j : (j.users || []);
|
||||
} catch { return []; }
|
||||
}
|
||||
async function persist(users) {
|
||||
await mkdir(path.dirname(USERS_FILE), { recursive: true });
|
||||
await writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
export async function hashPassword(pw) { return (await bcrypt()).hash(pw, 10); }
|
||||
export async function verifyPassword(pw, hash) {
|
||||
try { return await (await bcrypt()).compare(pw, hash); } catch { return false; }
|
||||
}
|
||||
|
||||
// Mint a GoTrue-shaped HS256 access token (verified by auth.js with JWT_SECRET).
|
||||
export async function mintToken(user, ttl = 3600) {
|
||||
if (!JWT_SECRET) throw new Error('JWT_SECRET fehlt — für auth: local nötig');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return sign({
|
||||
sub: user.id, email: user.email, role: 'authenticated',
|
||||
app_metadata: { role: user.role || 'user' }, iat: now, exp: now + ttl,
|
||||
}, JWT_SECRET, 'HS256');
|
||||
}
|
||||
|
||||
export async function login(email, password) {
|
||||
const users = await loadUsers();
|
||||
const u = users.find((x) => (x.email || '').toLowerCase() === (email || '').toLowerCase());
|
||||
if (!u || !u.password) return null;
|
||||
if (!(await verifyPassword(password, u.password))) return null;
|
||||
u.last_sign_in_at = new Date().toISOString();
|
||||
await persist(users);
|
||||
return {
|
||||
access_token: await mintToken(u),
|
||||
token_type: 'bearer',
|
||||
user: { id: u.id, email: u.email, role: u.role || 'user' },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Admin CRUD (mirrors routes/users.js shapes) ──────────────────────────────
|
||||
export async function listUsers(adminEmails = []) {
|
||||
const users = await loadUsers();
|
||||
return users.map((u) => {
|
||||
const fixedAdmin = adminEmails.includes((u.email || '').toLowerCase());
|
||||
const role = u.role || 'user';
|
||||
return {
|
||||
id: u.id, email: u.email, created_at: u.created_at || null,
|
||||
last_sign_in_at: u.last_sign_in_at || null,
|
||||
role: fixedAdmin ? 'admin' : role, isAdmin: fixedAdmin || role === 'admin', fixedAdmin,
|
||||
};
|
||||
});
|
||||
}
|
||||
export async function countByRole(adminEmails = []) {
|
||||
const list = await listUsers(adminEmails);
|
||||
const out = { total: list.length, admin: 0, editor: 0, user: 0 };
|
||||
for (const u of list) out[u.role] = (out[u.role] || 0) + 1;
|
||||
return out;
|
||||
}
|
||||
export async function createUser({ email, password, role }) {
|
||||
if (!email || !password) throw new Error('E-Mail und Passwort nötig');
|
||||
if (role && !ROLES.includes(role)) throw new Error('Unbekannte Rolle');
|
||||
const users = await loadUsers();
|
||||
if (users.some((u) => (u.email || '').toLowerCase() === email.toLowerCase())) {
|
||||
throw new Error('E-Mail existiert bereits');
|
||||
}
|
||||
const u = {
|
||||
id: randomUUID(), email, password: await hashPassword(password),
|
||||
role: role || 'user', created_at: new Date().toISOString(), last_sign_in_at: null,
|
||||
};
|
||||
users.push(u); await persist(users);
|
||||
return u.id;
|
||||
}
|
||||
export async function updateUser(id, { password, role }) {
|
||||
const users = await loadUsers();
|
||||
const u = users.find((x) => x.id === id);
|
||||
if (!u) throw new Error('Nutzer:in nicht gefunden');
|
||||
if (password) u.password = await hashPassword(password);
|
||||
if (role) { if (!ROLES.includes(role)) throw new Error('Unbekannte Rolle'); u.role = role; }
|
||||
await persist(users);
|
||||
}
|
||||
export async function deleteUser(id) {
|
||||
const users = await loadUsers();
|
||||
const next = users.filter((u) => u.id !== id);
|
||||
if (next.length === users.length) throw new Error('Nutzer:in nicht gefunden');
|
||||
await persist(next);
|
||||
}
|
||||
@@ -17,6 +17,9 @@ async function verifyToken(token) {
|
||||
return { id: p.sub, email: p.email || '', app_metadata: p.app_metadata || {} };
|
||||
} catch { return null; }
|
||||
}
|
||||
// Fallback (kein JWT_SECRET): Remote-Prüfung gegen GoTrue — nur wenn ein
|
||||
// Supabase-Auth-Client existiert (DB-loser Betrieb hat keinen).
|
||||
if (!supabaseAuth) return null;
|
||||
const { data, error } = await supabaseAuth.auth.getUser(token);
|
||||
if (error || !data?.user) return null;
|
||||
return data.user;
|
||||
@@ -0,0 +1,119 @@
|
||||
// Derive content classification, list ordering and path building from a site's
|
||||
// `config.collections` (see ../../README.md). Pure functions — no config import,
|
||||
// no filesystem — so they unit-test against any collections array directly.
|
||||
//
|
||||
// Fed `examples/openbureau.config.js` these reproduce the engine's original
|
||||
// hard-coded archiv/library/rubrik/seite behaviour 1:1.
|
||||
|
||||
// Parse a `path` pattern ('archiv/:section/:slug') into segments.
|
||||
function parsePattern(pattern) {
|
||||
return pattern.split('/').map((seg) =>
|
||||
seg.startsWith(':') ? { param: seg.slice(1) } : { literal: seg });
|
||||
}
|
||||
|
||||
// Match a content rel-path's segments (no .md) against a `path` pattern.
|
||||
// Returns captured params, or null. Strict: the segment count must equal the
|
||||
// pattern's, so 'archiv/:section/:slug' matches exactly three deep — the same
|
||||
// rule the original classify() enforced for archiv.
|
||||
function matchPattern(segs, pattern) {
|
||||
const pat = parsePattern(pattern);
|
||||
if (segs.length !== pat.length) return null;
|
||||
const params = {};
|
||||
for (let i = 0; i < pat.length; i++) {
|
||||
if (pat[i].literal !== undefined) {
|
||||
if (pat[i].literal !== segs[i]) return null;
|
||||
} else {
|
||||
if (!segs[i]) return null;
|
||||
params[pat[i].param] = segs[i];
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
// First literal segment of a pattern (e.g. 'library' from 'library/:slug').
|
||||
function rootSegment(pattern) {
|
||||
const first = pattern.split('/')[0];
|
||||
return first.startsWith(':') ? null : first;
|
||||
}
|
||||
|
||||
// { kind, section } for a content file, derived from collections.
|
||||
// Priority: index (_index.md) → path patterns → fallback — mirrors the original
|
||||
// hard-coded classify() when fed openbureau.config.js.
|
||||
export function classify(rel, collections) {
|
||||
const parts = rel.split('/');
|
||||
const base = parts[parts.length - 1];
|
||||
const segs = rel.replace(/\.md$/, '').split('/');
|
||||
|
||||
// _index.md → the index collection; section = parent dir (or 'home' at root).
|
||||
if (base === '_index.md') {
|
||||
const idx = collections.find((c) => c.index);
|
||||
if (idx) {
|
||||
const section = parts.length >= 2 ? parts[parts.length - 2] : 'home';
|
||||
return { kind: idx.kind, section };
|
||||
}
|
||||
}
|
||||
|
||||
// path-pattern collections (skip index/fallback).
|
||||
for (const c of collections) {
|
||||
if (!c.path || c.index || c.fallback) continue;
|
||||
const params = matchPattern(segs, c.path);
|
||||
if (params) {
|
||||
const section = params.section ?? rootSegment(c.path);
|
||||
return { kind: c.kind, section: section ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
// fallback collection (everything not otherwise matched).
|
||||
const fb = collections.find((c) => c.fallback);
|
||||
return { kind: fb ? fb.kind : null, section: null };
|
||||
}
|
||||
|
||||
// Sort comparator for listEntries: collection `order`, then date desc, then title.
|
||||
export function compareEntries(collections) {
|
||||
const order = {};
|
||||
collections.forEach((c, i) => { order[c.kind] = c.order ?? 100 + i; });
|
||||
return (a, b) =>
|
||||
((order[a.kind] ?? 999) - (order[b.kind] ?? 999)) ||
|
||||
(b.date || '').localeCompare(a.date || '') ||
|
||||
a.title.localeCompare(b.title);
|
||||
}
|
||||
|
||||
// Build a content rel-path for a NEW entry of `kind` from form `data`.
|
||||
// Index collection → '<slug>/_index.md'; a collection without a `path` (fallback)
|
||||
// → '<slug>.md'; otherwise substitute :params in the collection's `path`
|
||||
// (archiv/:section/:slug → archiv/<section>/<slug>.md). Returns '' if a required
|
||||
// segment is missing.
|
||||
export function buildPath(kind, data, collections) {
|
||||
const c = collections.find((x) => x.kind === kind);
|
||||
const slug = String(data.slug || '').trim();
|
||||
if (c?.index) return slug ? `${slug}/_index.md` : '';
|
||||
if (!c?.path) return slug ? `${slug}.md` : '';
|
||||
let ok = true;
|
||||
const rel = c.path.split('/').map((seg) => {
|
||||
if (!seg.startsWith(':')) return seg;
|
||||
const v = String(data[seg.slice(1)] || '').trim();
|
||||
if (!v) ok = false;
|
||||
return v;
|
||||
}).join('/');
|
||||
return ok && rel ? `${rel}.md` : '';
|
||||
}
|
||||
|
||||
// Build the stats `content` object from classified entries + collections:
|
||||
// one counter per collection.statKey, plus a drafts counter for any collection
|
||||
// that declares `draftStatKey`. Mirrors the original hard-coded stats block
|
||||
// (beitraege/entwuerfe/library/seiten/rubriken) when fed openbureau.config.js.
|
||||
export function contentStats(entries, collections) {
|
||||
const content = {};
|
||||
for (const c of collections) {
|
||||
if (c.statKey) content[c.statKey] ??= 0;
|
||||
if (c.draftStatKey) content[c.draftStatKey] ??= 0;
|
||||
}
|
||||
const byKind = Object.fromEntries(collections.map((c) => [c.kind, c]));
|
||||
for (const e of entries) {
|
||||
const c = byKind[e.kind];
|
||||
if (!c) continue;
|
||||
if (c.statKey) content[c.statKey]++;
|
||||
if (c.draftStatKey && e.draft) content[c.draftStatKey]++;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Per-site CMS configuration loader.
|
||||
//
|
||||
// A site tells the core what it manages through one module, referenced by the
|
||||
// CMS_CONFIG env var (e.g. CMS_CONFIG=/site/cms.config.js). The module exports
|
||||
// `default` with { collections, plugins, admins }. Loaded once at startup; the
|
||||
// engine (files/stats/routes) derives its behaviour from it instead of having
|
||||
// openbureau-specific content types hard-coded.
|
||||
//
|
||||
// See ../../README.md for the schema, and ../../examples/*.config.js.
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const p = process.env.CMS_CONFIG;
|
||||
if (!p) {
|
||||
console.error('FEHLT: CMS_CONFIG (Pfad zur Site-Config, z. B. /site/cms.config.js)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mod = await import(pathToFileURL(p).href);
|
||||
export const config = mod.default || mod;
|
||||
config.collections ||= [];
|
||||
config.plugins ||= [];
|
||||
config.admins ||= [];
|
||||
config.auth ||= 'supabase'; // 'supabase' (GoTrue) | 'local' (file users, DB-less)
|
||||
|
||||
export const hasPlugin = (name) => config.plugins.includes(name);
|
||||
|
||||
// Collection lookup helpers (used by the generalised files.js / stats.js).
|
||||
export const collectionOf = (kind) => config.collections.find((c) => c.kind === kind) || null;
|
||||
export const fallbackCollection = () => config.collections.find((c) => c.fallback) || null;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import matter from 'gray-matter';
|
||||
import { classify, compareEntries } from './collections.js';
|
||||
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
const CONTENT = path.join(SITE_DIR, 'content');
|
||||
@@ -26,20 +27,6 @@ async function walk(dir) {
|
||||
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 };
|
||||
}
|
||||
|
||||
// authors-Frontmatter zu Array normalisieren (String oder Array erlaubt).
|
||||
export function normAuthors(a) {
|
||||
if (Array.isArray(a)) return a.map(String).filter(Boolean);
|
||||
@@ -62,6 +49,9 @@ export function urlFor(rel) {
|
||||
}
|
||||
|
||||
export async function listEntries() {
|
||||
// Lazy: only listing needs the site config, so importing files.js for its pure
|
||||
// helpers (safeRel/urlFor/…) never triggers the CMS_CONFIG load.
|
||||
const { config } = await import('./config.js');
|
||||
const files = await walk(CONTENT);
|
||||
const items = [];
|
||||
for (const full of files) {
|
||||
@@ -73,7 +63,7 @@ export async function listEntries() {
|
||||
items.push({
|
||||
path: rel,
|
||||
title: data.title || rel,
|
||||
...classify(rel),
|
||||
...classify(rel, config.collections),
|
||||
color: data.color || null,
|
||||
layout: data.layout || null,
|
||||
draft: !!data.draft,
|
||||
@@ -82,12 +72,8 @@ export async function listEntries() {
|
||||
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));
|
||||
// Reihenfolge aus der Config (collection.order), dann Datum, dann Titel.
|
||||
items.sort(compareEntries(config.collections));
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -11,13 +11,23 @@ import publish from './routes/publish.js';
|
||||
import upload from './routes/upload.js';
|
||||
import profile from './routes/profile.js';
|
||||
import users from './routes/users.js';
|
||||
import { listComments, createComment, deleteComment, login } from './routes/comments.js';
|
||||
import stats from './routes/stats.js';
|
||||
import system from './routes/system.js';
|
||||
import account from './routes/account.js';
|
||||
import history from './routes/history.js';
|
||||
import {
|
||||
listForums, showForum, recent, threadInfo, newThread, mod, adminForums,
|
||||
} from './routes/dialog.js';
|
||||
import { requireAuth } from './auth.js';
|
||||
import { syncLibrary } from './dialog-store.js';
|
||||
import authRoute from './routes/auth.js';
|
||||
import { requireAuth, requireAdmin } from './auth.js';
|
||||
import { config } from './config.js';
|
||||
import { supabase } from './supabase.js';
|
||||
import { manager } from './plugins.js';
|
||||
import { runMigrations } from './migrate.js';
|
||||
|
||||
// Fail-fast: auth:'supabase' ohne erreichbaren Service-Client ist ein Fehlstart.
|
||||
// (auth:'local' braucht keinen — DB-loser Betrieb.)
|
||||
if (config.auth === 'supabase' && !supabase) {
|
||||
console.error('FEHLT: SUPABASE_URL/SERVICE_KEY für auth: supabase');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
|
||||
@@ -60,17 +70,16 @@ app.use('/api/*', (c, next) =>
|
||||
c.req.path.startsWith('/api/upload') ? next() : jsonBodyLimit(c, next));
|
||||
|
||||
app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' }));
|
||||
// Öffentlich (ohne Login): Dialog lesen, Übersicht, Login fürs Dialog-Widget.
|
||||
app.get('/api/comments', listComments);
|
||||
app.get('/api/forums', listForums);
|
||||
app.get('/api/forums/:slug', showForum);
|
||||
app.get('/api/recent', recent);
|
||||
app.get('/api/thread', threadInfo);
|
||||
// Öffentliche Plugin-Routen (ohne Login) — je nach config.plugins, z. B. Dialog
|
||||
// lesen + Widget-Login. Jede Route bringt ihre eigenen Limits mit (dialog drosselt
|
||||
// den Login auf 10 Versuche/IP pro 5 Minuten). Pluginlose Site: nichts gemountet.
|
||||
manager.mountPublic(app);
|
||||
// Lokaler Login (nur auth: 'local') — ersetzt GoTrue, öffentlich + gedrosselt.
|
||||
// Supabase-Sites loggen direkt gegen GoTrue ein und brauchen das nicht.
|
||||
if (config.auth === 'local') app.route('/api/auth', authRoute);
|
||||
// Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar.
|
||||
app.route('/api/history', history);
|
||||
// Login gegen Brute-Force drosseln: max. 10 Versuche/IP pro 5 Minuten.
|
||||
app.post('/api/auth/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), login);
|
||||
// Alles weitere unter /api/* braucht ein gültiges Supabase-Token.
|
||||
// Alles weitere unter /api/* braucht ein gültiges Token (Supabase oder lokal).
|
||||
app.use('/api/*', requireAuth);
|
||||
// Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token):
|
||||
// 60 Mutationen/Minute je Nutzer. Lesen (GET) bleibt frei.
|
||||
@@ -80,17 +89,27 @@ const mutateLimit = rateLimit({
|
||||
});
|
||||
app.use('/api/*', (c, next) => (c.req.method === 'GET' ? next() : mutateLimit(c, next)));
|
||||
app.get('/api/me', (c) => c.json({ email: c.get('email'), role: c.get('role'), isAdmin: c.get('isAdmin'), canModerate: c.get('canModerate') }));
|
||||
app.post('/api/comments', createComment);
|
||||
app.delete('/api/comments/:id', deleteComment);
|
||||
app.post('/api/threads', newThread);
|
||||
app.route('/api/mod', mod);
|
||||
app.route('/api/admin/forums', adminForums);
|
||||
// Authentifizierte Plugin-Routen (nach requireAuth). admin:true-Routen laufen
|
||||
// hinter requireAdmin; self-guarding Sub-Apps (dialog mod/adminForums) regeln den
|
||||
// Zugriff selbst und brauchen das Flag nicht.
|
||||
manager.mountPrivate(app, '/api', requireAdmin);
|
||||
app.route('/api/content', content);
|
||||
app.route('/api/preview', preview);
|
||||
app.route('/api/publish', publish);
|
||||
app.route('/api/upload', upload);
|
||||
app.route('/api/profile', profile);
|
||||
app.route('/api/users', users);
|
||||
app.route('/api/stats', stats);
|
||||
app.route('/api/system', system);
|
||||
app.route('/api/account', account);
|
||||
// Content-Modell der Site fürs Admin (schema-getriebenes Rendern). Jede:r
|
||||
// eingeloggte Nutzer:in darf es lesen — der Editor braucht es zum Aufbauen.
|
||||
app.get('/api/schema', (c) => c.json({
|
||||
site: config.site || null,
|
||||
auth: config.auth || 'supabase',
|
||||
plugins: config.plugins,
|
||||
collections: config.collections,
|
||||
}));
|
||||
|
||||
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
|
||||
app.get('/admin', (c) => c.redirect('/admin/'));
|
||||
@@ -118,8 +137,12 @@ app.use('/images/*', serveStatic({ root: `${SITE_DIR}/static` }));
|
||||
// --- Live-Site (gebaut nach public/) ---
|
||||
app.use('/*', serveStatic({ root: `${SITE_DIR}/public` }));
|
||||
|
||||
serve({ fetch: app.fetch, port: PORT }, (info) => {
|
||||
console.log(`OPENBUREAU CMS läuft auf :${info.port} — Site + API + /_preview`);
|
||||
// Library-Beiträge als Threads in „Beiträge" spiegeln (nicht blockierend).
|
||||
syncLibrary().catch((e) => console.error('syncLibrary:', e?.message || e));
|
||||
serve({ fetch: app.fetch, port: PORT }, async (info) => {
|
||||
console.log(`CMS läuft auf :${info.port} — Site + API + /_preview`);
|
||||
// Plugin-Migrationen zuerst anwenden (run-once, getrackt) — die Boot-Hooks
|
||||
// unten brauchen die Tabellen schon. Ohne deklarierte Migrationen ein No-op.
|
||||
await runMigrations(manager, { databaseUrl: process.env.DATABASE_URL })
|
||||
.catch((e) => console.error('migrate:', e?.message || e));
|
||||
// Plugin-Boot-Hooks anstoßen (dialog z. B. spiegelt Library→Threads), nicht blockierend.
|
||||
manager.runBoot({}).catch((e) => console.error('plugin onBoot:', e?.message || e));
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// Migration runner — applies plugin-declared migrations once each, tracked in
|
||||
// public.schema_migrations. The plugin manager only *surfaces* migrations()
|
||||
// (file URLs); this is what actually runs them, at boot.
|
||||
//
|
||||
// Design for a lean / DB-optional core:
|
||||
// * No declared migrations (e.g. kgva: no plugins) → returns immediately, never
|
||||
// opens a DB connection and never imports `pg`.
|
||||
// * `exec` is injectable — an async (text, params?) => rows function — so the
|
||||
// logic unit-tests without a real database. Omit it and the runner connects
|
||||
// via `pg` to `databaseUrl` (lazy import; clear error if `pg` is missing).
|
||||
// * Each migration runs once (id = "<plugin>/<file>") inside its own
|
||||
// transaction; the file SQL is itself idempotent (see 001_dialog.sql), so a
|
||||
// pre-existing DB (openbureau) is baselined cleanly on first run.
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export async function runMigrations(manager, { databaseUrl, exec, log = console } = {}) {
|
||||
const migs = manager.migrations();
|
||||
if (!migs.length) return { applied: [], skipped: [], pending: false };
|
||||
|
||||
let close = async () => {};
|
||||
if (!exec) {
|
||||
if (!databaseUrl) {
|
||||
log.warn?.(`migrate: ${migs.length} Migration(en) deklariert, aber DATABASE_URL fehlt — übersprungen.`);
|
||||
return { applied: [], skipped: migs.map((m) => `${m.plugin}/${m.file}`), pending: true };
|
||||
}
|
||||
({ exec, close } = await pgExecutor(databaseUrl));
|
||||
}
|
||||
|
||||
try {
|
||||
await exec(`CREATE TABLE IF NOT EXISTS public.schema_migrations (
|
||||
id text PRIMARY KEY,
|
||||
plugin text NOT NULL,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
)`);
|
||||
|
||||
const applied = [], skipped = [];
|
||||
for (const m of migs) {
|
||||
const id = `${m.plugin}/${m.file}`;
|
||||
const done = await exec('SELECT 1 FROM public.schema_migrations WHERE id = $1', [id]);
|
||||
if (done?.length) { skipped.push(id); continue; }
|
||||
try {
|
||||
const sql = await readFile(fileURLToPath(m.url), 'utf8');
|
||||
await exec('BEGIN');
|
||||
try {
|
||||
await exec(sql);
|
||||
await exec('INSERT INTO public.schema_migrations (id, plugin) VALUES ($1, $2)', [id, m.plugin]);
|
||||
await exec('COMMIT');
|
||||
} catch (e) {
|
||||
await exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Migration ${id} fehlgeschlagen: ${e.message}`);
|
||||
}
|
||||
applied.push(id);
|
||||
log.log?.(`migrate: angewandt ${id}`);
|
||||
}
|
||||
return { applied, skipped, pending: false };
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
}
|
||||
|
||||
// Default executor: one pg client over databaseUrl. `pg` is imported lazily so a
|
||||
// DB-less instance never pulls it in at runtime.
|
||||
async function pgExecutor(databaseUrl) {
|
||||
let pg;
|
||||
try { pg = (await import('pg')).default; }
|
||||
catch { throw new Error("migrate: Paket 'pg' fehlt (npm i pg) — für Plugin-Migrationen nötig."); }
|
||||
const client = new pg.Client({ connectionString: databaseUrl });
|
||||
await client.connect();
|
||||
const exec = async (text, params) => (await client.query(text, params)).rows;
|
||||
return { exec, close: () => client.end() };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Plugin manager: everything beyond the generic engine is a plugin (see README →
|
||||
// "Plugins"). A plugin is `api/src/plugins/<name>/index.js` exporting a manifest:
|
||||
// { name, routes:[…], onBoot, onPublish, onPreview, migrations:[…], stats, admin:{…} }
|
||||
//
|
||||
// A route entry is either a mounted sub-app or a single handler:
|
||||
// { path, app, public?, admin? } // app = Hono instance
|
||||
// { method, path, handler, public?, admin?, use?:[mw] } // method = get|post|put|delete
|
||||
// `public: true` → mounted before requireAuth; `admin: true` → guarded with the
|
||||
// supplied requireAdmin; `use` adds per-route middleware (e.g. a rate limit).
|
||||
//
|
||||
// The manager loads the manifests named in config.plugins and wires them in:
|
||||
// mounts routes by auth tier, runs lifecycle hooks, merges stats, surfaces
|
||||
// migrations. Pure wiring — plugins import what they need (supabase, files, …).
|
||||
|
||||
const PLUGINS_DIR = new URL('./plugins/', import.meta.url);
|
||||
|
||||
// Import the manifests for `names` (config.plugins) and return a manager.
|
||||
export async function loadPlugins(names = [], dir = PLUGINS_DIR) {
|
||||
const manifests = [];
|
||||
for (const name of names) {
|
||||
const url = new URL(`${name}/index.js`, dir);
|
||||
const mod = await import(url.href);
|
||||
const manifest = mod.default || mod;
|
||||
if (!manifest?.name) throw new Error(`Plugin "${name}": ungültiges Manifest (name fehlt)`);
|
||||
manifest.__dir = new URL(`${name}/`, dir);
|
||||
manifests.push(manifest);
|
||||
}
|
||||
return createManager(manifests);
|
||||
}
|
||||
|
||||
// Mount one route entry (sub-app or handler) at `full`, behind `guards` (+ its
|
||||
// own `use` middleware).
|
||||
function mountRoute(app, full, r, guards) {
|
||||
const mws = [...guards, ...(r.use || [])];
|
||||
if (r.app) {
|
||||
for (const mw of mws) { app.use(full, mw); app.use(full + '/*', mw); }
|
||||
app.route(full, r.app);
|
||||
} else if (r.method && r.handler) {
|
||||
app[r.method](full, ...mws, r.handler);
|
||||
} else {
|
||||
throw new Error(`Plugin-Route ${full}: braucht entweder app oder method+handler`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build a manager over already-loaded manifests (kept separate from import so it
|
||||
// unit-tests without touching the filesystem).
|
||||
export function createManager(manifests) {
|
||||
const plugins = (manifests || []).filter(Boolean);
|
||||
const routesWhere = (pred) =>
|
||||
plugins.flatMap((p) => (p.routes || []).filter(pred).map((r) => ({ plugin: p.name, ...r })));
|
||||
|
||||
return {
|
||||
plugins,
|
||||
names: () => plugins.map((p) => p.name),
|
||||
has: (name) => plugins.some((p) => p.name === name),
|
||||
|
||||
// Public routes (no auth) — mount BEFORE `requireAuth` in the pipeline.
|
||||
mountPublic(app, prefix = '/api') {
|
||||
for (const r of routesWhere((r) => r.public)) mountRoute(app, prefix + r.path, r, []);
|
||||
},
|
||||
|
||||
// Authenticated routes — mount AFTER `requireAuth`. `admin: true` routes are
|
||||
// guarded with the supplied requireAdmin (sub-apps that self-guard can omit
|
||||
// the flag, as dialog's mod/adminForums do).
|
||||
mountPrivate(app, prefix = '/api', requireAdmin) {
|
||||
for (const r of routesWhere((r) => !r.public)) {
|
||||
const guards = r.admin && requireAdmin ? [requireAdmin] : [];
|
||||
mountRoute(app, prefix + r.path, r, guards);
|
||||
}
|
||||
},
|
||||
|
||||
// Lifecycle hooks (awaited in plugin order).
|
||||
async runBoot(ctx) { for (const p of plugins) if (p.onBoot) await p.onBoot(ctx); },
|
||||
async runPublish(ctx, payload) { for (const p of plugins) if (p.onPublish) await p.onPublish(ctx, payload); },
|
||||
async runPreview(ctx, payload) { for (const p of plugins) if (p.onPreview) await p.onPreview(ctx, payload); },
|
||||
|
||||
// Merge each plugin's stats() under its own name: { dialog: { forums, … } }.
|
||||
async collectStats(ctx) {
|
||||
const out = {};
|
||||
for (const p of plugins) if (p.stats) out[p.name] = await p.stats(ctx);
|
||||
return out;
|
||||
},
|
||||
|
||||
// Declared migrations resolved to file URLs; EXECUTION is the caller's job
|
||||
// (needs the live Postgres — wired in stage 5/boot, not here).
|
||||
migrations() {
|
||||
return plugins.flatMap((p) =>
|
||||
(p.migrations || []).map((file) => ({
|
||||
plugin: p.name,
|
||||
file,
|
||||
url: p.__dir ? new URL(`migrations/${file}`, p.__dir).href : null,
|
||||
})));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// The plugin manager for this instance, loaded once from config.plugins.
|
||||
//
|
||||
// Shared singleton: index.js wires its routes/hooks, publish.js fires onPublish,
|
||||
// stats.js merges collectStats. A pluginless site (config.plugins = []) gets an
|
||||
// empty manager — every call is a no-op, zero DB reads.
|
||||
import { loadPlugins } from './plugin-manager.js';
|
||||
import { config } from './config.js';
|
||||
|
||||
export const manager = await loadPlugins(config.plugins);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { supabase, supabaseAuth } from '../supabase.js';
|
||||
import { roleOf } from '../auth.js';
|
||||
import { profileFor, threadLocked } from '../dialog-store.js';
|
||||
import { serverError } from '../util.js';
|
||||
import { supabase, supabaseAuth } from '../../supabase.js';
|
||||
import { roleOf } from '../../auth.js';
|
||||
import { profileFor, threadLocked } from './dialog-store.js';
|
||||
import { serverError } from '../../util.js';
|
||||
|
||||
// Dialog: flache Wortmeldungen pro Thread (= Thread-Key), optionaler Bezug.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { supabase } from './supabase.js';
|
||||
import { listEntries } from './files.js';
|
||||
import { supabase } from '../../supabase.js';
|
||||
import { listEntries } from '../../files.js';
|
||||
|
||||
// Daten-Schicht für den Dialog (Foren + Threads + Wortmeldungen).
|
||||
// Alle DB-Zugriffe laufen über den Service-Client (umgeht RLS).
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../supabase.js';
|
||||
import { requireAdmin, requireModerator } from '../auth.js';
|
||||
import { serverError } from '../util.js';
|
||||
import { supabase } from '../../supabase.js';
|
||||
import { requireAdmin, requireModerator } from '../../auth.js';
|
||||
import { serverError } from '../../util.js';
|
||||
import {
|
||||
forumsWithCounts, forumWithThreads, recentComments, createThread, recentForModeration, threadMeta,
|
||||
} from '../dialog-store.js';
|
||||
} from './dialog-store.js';
|
||||
|
||||
// Fehlt die Tabelle (Migration noch nicht eingespielt), nicht mit einem rohen
|
||||
// SQL-Fehler antworten — leer zurückgeben und server-seitig laut loggen.
|
||||
@@ -0,0 +1,74 @@
|
||||
// dialog plugin — openbureau's forum/comment subsystem as a core plugin.
|
||||
// Wraps the existing dialog modules (no logic change) into a plugin manifest:
|
||||
// public reads + widget login, authenticated writes, self-guarding moderation /
|
||||
// forum-admin sub-apps, syncLibrary on boot + after publish, and the forum
|
||||
// counters merged into /api/stats. kgva does not enable this plugin.
|
||||
//
|
||||
// NB: this manifest exists; wiring it into index.js (and removing the hard-coded
|
||||
// dialog there) is the live-tested cutover (stage 5) — see HANDOVER.
|
||||
import { rateLimit } from '../../ratelimit.js';
|
||||
import {
|
||||
listForums, showForum, recent, threadInfo, newThread, mod, adminForums,
|
||||
} from './dialog.js';
|
||||
import { listComments, createComment, deleteComment, login } from './comments.js';
|
||||
import { supabase } from '../../supabase.js';
|
||||
import { syncLibrary } from './dialog-store.js';
|
||||
|
||||
export default {
|
||||
name: 'dialog',
|
||||
|
||||
routes: [
|
||||
// ── public reads (mounted before requireAuth) ──
|
||||
{ method: 'get', path: '/comments', handler: listComments, public: true },
|
||||
{ method: 'get', path: '/forums', handler: listForums, public: true },
|
||||
{ method: 'get', path: '/forums/:slug', handler: showForum, public: true },
|
||||
{ method: 'get', path: '/recent', handler: recent, public: true },
|
||||
{ method: 'get', path: '/thread', handler: threadInfo, public: true },
|
||||
// widget login — brute-force throttled (10 / 5 min per IP), as in index.js
|
||||
{
|
||||
method: 'post', path: '/auth/login', handler: login, public: true,
|
||||
use: [rateLimit({ max: 10, windowMs: 5 * 60_000 })],
|
||||
},
|
||||
|
||||
// ── authenticated writes (mounted after requireAuth) ──
|
||||
{ method: 'post', path: '/comments', handler: createComment },
|
||||
{ method: 'delete', path: '/comments/:id', handler: deleteComment },
|
||||
{ method: 'post', path: '/threads', handler: newThread },
|
||||
|
||||
// ── self-guarding sub-apps (requireModerator / requireAdmin inside) ──
|
||||
{ path: '/mod', app: mod },
|
||||
{ path: '/admin/forums', app: adminForums },
|
||||
],
|
||||
|
||||
// Mirror library posts as threads — on boot and after every publish build.
|
||||
onBoot: async () => {
|
||||
await syncLibrary().catch((e) => console.error('syncLibrary:', e?.message || e));
|
||||
},
|
||||
onPublish: async () => {
|
||||
await syncLibrary({ force: true }).catch(() => {});
|
||||
},
|
||||
|
||||
// Forum/thread/comment counters, merged into /api/stats under "dialog".
|
||||
stats: async () => {
|
||||
const count = async (table, filter) => {
|
||||
try {
|
||||
let q = supabase.from(table).select('*', { count: 'exact', head: true });
|
||||
if (filter) q = filter(q);
|
||||
const { count: n } = await q;
|
||||
return n || 0;
|
||||
} catch { return 0; }
|
||||
};
|
||||
const [forums, threads, comments] = await Promise.all([
|
||||
count('forums'),
|
||||
count('threads', (q) => q.eq('deleted', false)),
|
||||
count('comments', (q) => q.eq('deleted', false)),
|
||||
]);
|
||||
return { forums, threads, comments };
|
||||
},
|
||||
|
||||
// Schema (forums/threads/comments + comment_stats view) — captured from the live
|
||||
// openbureau dev DB into migrations/001_dialog.sql (idempotent; verified to apply
|
||||
// on a fresh DB and re-apply as a no-op). The manager surfaces it; a migration
|
||||
// runner applies it on boot for a fresh instance (execution still TODO).
|
||||
migrations: ['001_dialog.sql'],
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
-- dialog plugin schema — forums / threads / comments (+ comment_stats view).
|
||||
--
|
||||
-- Captured 2026-06-29 from the live openbureau dev DB (CT 134 → openbureau-db,
|
||||
-- supabase/postgres 15.8) via `pg_dump --schema-only --no-owner --no-privileges`
|
||||
-- of public.{forums,threads,comments,comment_stats}. Made idempotent (IF NOT
|
||||
-- EXISTS / OR REPLACE / constraint guards) so it is a no-op against the existing
|
||||
-- openbureau DB and safe to apply to a fresh instance.
|
||||
--
|
||||
-- Notes:
|
||||
-- * gen_random_uuid() is built into Postgres 13+ — no pgcrypto extension needed.
|
||||
-- * RLS is enabled with NO policies: every read/write is mediated by the CMS
|
||||
-- using the Supabase service_role key (which bypasses RLS). The dialog plugin
|
||||
-- therefore only makes sense with `auth: supabase`; kgva (local auth) omits it.
|
||||
-- * `posts` exists in the live DB but the dialog code never touches it — NOT a
|
||||
-- dialog table, intentionally excluded.
|
||||
|
||||
-- ── tables ──────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.forums (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
slug text NOT NULL,
|
||||
name text NOT NULL,
|
||||
description text DEFAULT ''::text,
|
||||
color text,
|
||||
sort integer DEFAULT 0 NOT NULL,
|
||||
kind text DEFAULT 'forum'::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.threads (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
forum_id uuid,
|
||||
key text NOT NULL,
|
||||
title text NOT NULL,
|
||||
url text,
|
||||
kind text DEFAULT 'forum'::text NOT NULL,
|
||||
author_name text,
|
||||
user_id uuid,
|
||||
locked boolean DEFAULT false NOT NULL,
|
||||
deleted boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.comments (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
thread text NOT NULL,
|
||||
parent_id uuid,
|
||||
user_id uuid,
|
||||
author_name text,
|
||||
author_avatar text,
|
||||
body text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
deleted boolean DEFAULT false NOT NULL,
|
||||
author_role text
|
||||
);
|
||||
|
||||
-- ── constraints (guarded — Postgres has no ADD CONSTRAINT IF NOT EXISTS) ──────
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'forums_pkey') THEN
|
||||
ALTER TABLE ONLY public.forums ADD CONSTRAINT forums_pkey PRIMARY KEY (id);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'forums_slug_key') THEN
|
||||
ALTER TABLE ONLY public.forums ADD CONSTRAINT forums_slug_key UNIQUE (slug);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'threads_pkey') THEN
|
||||
ALTER TABLE ONLY public.threads ADD CONSTRAINT threads_pkey PRIMARY KEY (id);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'threads_key_key') THEN
|
||||
ALTER TABLE ONLY public.threads ADD CONSTRAINT threads_key_key UNIQUE (key);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'comments_pkey') THEN
|
||||
ALTER TABLE ONLY public.comments ADD CONSTRAINT comments_pkey PRIMARY KEY (id);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'threads_forum_id_fkey') THEN
|
||||
ALTER TABLE ONLY public.threads ADD CONSTRAINT threads_forum_id_fkey
|
||||
FOREIGN KEY (forum_id) REFERENCES public.forums(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'comments_parent_id_fkey') THEN
|
||||
ALTER TABLE ONLY public.comments ADD CONSTRAINT comments_parent_id_fkey
|
||||
FOREIGN KEY (parent_id) REFERENCES public.comments(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── indexes ───────────────────────────────────────────────────────────────────
|
||||
CREATE INDEX IF NOT EXISTS threads_forum_idx ON public.threads USING btree (forum_id);
|
||||
CREATE INDEX IF NOT EXISTS comments_thread_idx ON public.comments USING btree (thread, created_at);
|
||||
|
||||
-- ── view: per-thread comment counts (used by dialog-store) ────────────────────
|
||||
CREATE OR REPLACE VIEW public.comment_stats AS
|
||||
SELECT comments.thread,
|
||||
(count(*))::integer AS count,
|
||||
max(comments.created_at) AS last
|
||||
FROM public.comments
|
||||
WHERE (NOT comments.deleted)
|
||||
GROUP BY comments.thread;
|
||||
|
||||
-- ── row-level security (enabled, no policies — service_role-mediated) ─────────
|
||||
ALTER TABLE public.forums ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.threads ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.comments ENABLE ROW LEVEL SECURITY;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Hono } from 'hono';
|
||||
import { config } from '../config.js';
|
||||
import { supabase, supabaseAuth } from '../supabase.js';
|
||||
import * as local from '../auth-local.js';
|
||||
|
||||
// Self-service for the logged-in user (after requireAuth). Currently: change own
|
||||
// password — local provider edits the user file; supabase verifies the current
|
||||
// password then updates via the admin API.
|
||||
const account = new Hono();
|
||||
|
||||
account.post('/password', async (c) => {
|
||||
let body = {};
|
||||
try { body = await c.req.json(); } catch { /* 400 below */ }
|
||||
const { current, next } = body;
|
||||
if (!next || String(next).length < 6) return c.json({ error: 'Neues Passwort zu kurz (min. 6 Zeichen).' }, 400);
|
||||
const email = (c.get('email') || '').toLowerCase();
|
||||
const userId = c.get('user')?.id;
|
||||
|
||||
if (config.auth === 'local') {
|
||||
const u = (await local.loadUsers()).find((x) => (x.email || '').toLowerCase() === email);
|
||||
if (!u || !(await local.verifyPassword(current || '', u.password))) {
|
||||
return c.json({ error: 'Aktuelles Passwort ist falsch.' }, 403);
|
||||
}
|
||||
await local.updateUser(u.id, { password: next });
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
|
||||
// supabase: verify current via the dedicated auth client, then update via admin.
|
||||
if (!supabaseAuth || !supabase) return c.json({ error: 'Auth nicht verfügbar' }, 500);
|
||||
const { error: e1 } = await supabaseAuth.auth.signInWithPassword({ email, password: current || '' });
|
||||
await supabaseAuth.auth.signOut().catch(() => {}); // Session sofort wieder lösen
|
||||
if (e1) return c.json({ error: 'Aktuelles Passwort ist falsch.' }, 403);
|
||||
const { error: e2 } = await supabase.auth.admin.updateUserById(userId, { password: next });
|
||||
if (e2) return c.json({ error: e2.message }, 400);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export default account;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Hono } from 'hono';
|
||||
import { rateLimit } from '../ratelimit.js';
|
||||
import { login } from '../auth-local.js';
|
||||
|
||||
// Lokaler Login (auth: 'local') — ersetzt GoTrue. Liefert ein GoTrue-förmiges
|
||||
// Token, das requireAuth genauso prüft wie ein Supabase-Token. Nur gemountet,
|
||||
// wenn config.auth === 'local' (siehe index.js); Brute-Force gedrosselt.
|
||||
const auth = new Hono();
|
||||
|
||||
auth.post('/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), async (c) => {
|
||||
let body = {};
|
||||
try { body = await c.req.json(); } catch { /* leerer/kaputter Body → 400 unten */ }
|
||||
const { email, password } = body;
|
||||
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
||||
const res = await login(email, password);
|
||||
if (!res) return c.json({ error: 'Login fehlgeschlagen' }, 401);
|
||||
return c.json(res);
|
||||
});
|
||||
|
||||
export default auth;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { urlFor, safeRel } from '../files.js';
|
||||
import { buildSite, gitCommit } from '../hugo.js';
|
||||
import { syncLibrary } from '../dialog-store.js';
|
||||
import { manager } from '../plugins.js';
|
||||
|
||||
// Publizieren: public/ neu bauen (ohne Drafts) → live. Optional git-commit.
|
||||
const publish = new Hono();
|
||||
@@ -11,8 +11,9 @@ publish.post('/', async (c) => {
|
||||
try {
|
||||
const safe = safeRel(rel);
|
||||
const build = await buildSite({ dest: 'public', drafts: false });
|
||||
// Neue/aktualisierte Library-Beiträge sofort als Dialog-Threads spiegeln.
|
||||
await syncLibrary({ force: true }).catch(() => {});
|
||||
// Plugin-Publish-Hooks anstoßen (dialog spiegelt neue Library-Beiträge sofort
|
||||
// als Threads). Fehler dürfen das Publish nicht kippen — Hooks fangen selbst ab.
|
||||
await manager.runPublish({}, { path: safe });
|
||||
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) {
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../supabase.js';
|
||||
import { listEntries } from '../files.js';
|
||||
import { requireAdmin, roleOf } from '../auth.js';
|
||||
import { config } from '../config.js';
|
||||
import { contentStats } from '../collections.js';
|
||||
import { manager } from '../plugins.js';
|
||||
import { countByRole } from '../auth-local.js';
|
||||
|
||||
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
|
||||
// Kennzahlen für die Admin-Übersicht. Nur Admins; rein lesend.
|
||||
const stats = new Hono();
|
||||
stats.use('*', requireAdmin);
|
||||
|
||||
stats.get('/', async (c) => {
|
||||
// Inhalte aus dem Dateisystem zählen — Zähler pro collection.statKey.
|
||||
let content = {};
|
||||
try { content = contentStats(await listEntries(), config.collections); }
|
||||
catch { /* Filesystem nicht lesbar → leer */ }
|
||||
|
||||
// Nutzer nach Rolle: lokal aus der User-Datei, sonst aus GoTrue.
|
||||
let users = { total: 0, admin: 0, editor: 0, user: 0 };
|
||||
if (config.auth === 'local') {
|
||||
try { users = await countByRole(ADMIN_EMAILS); } catch { /* User-Datei fehlt → 0 */ }
|
||||
} else if (supabase) {
|
||||
try {
|
||||
const { data } = await supabase.auth.admin.listUsers();
|
||||
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; }
|
||||
} catch { /* GoTrue nicht erreichbar */ }
|
||||
}
|
||||
|
||||
// Plugin-Kennzahlen einsammeln (jedes Plugin unter seinem Namen). Nur geladene
|
||||
// Plugins lesen die DB — eine pluginlose Site macht hier null DB-Zugriffe.
|
||||
const pluginStats = await manager.collectStats({});
|
||||
// Dialog bleibt als eigenes Feld im Vertrag (Admin erwartet { forums, threads,
|
||||
// comments }); fehlt das Plugin, sind es Nullen.
|
||||
const dialog = pluginStats.dialog || { forums: 0, threads: 0, comments: 0 };
|
||||
|
||||
return c.json({ content, users, dialog });
|
||||
});
|
||||
|
||||
export default stats;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Hono } from 'hono';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { config } from '../config.js';
|
||||
import { manager } from '../plugins.js';
|
||||
import { requireAdmin } from '../auth.js';
|
||||
|
||||
const execFileP = promisify(execFile);
|
||||
|
||||
let _version = null;
|
||||
async function coreVersion() {
|
||||
if (_version) return _version;
|
||||
try { _version = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8')).version || '0.0.0'; }
|
||||
catch { _version = '0.0.0'; }
|
||||
return _version;
|
||||
}
|
||||
let _hugo = null;
|
||||
async function hugoVersion() {
|
||||
if (_hugo) return _hugo;
|
||||
try { const { stdout } = await execFileP('hugo', ['version']); _hugo = (stdout.match(/v[0-9][0-9.]*[^\s]*/) || ['?'])[0]; }
|
||||
catch { _hugo = '?'; }
|
||||
return _hugo;
|
||||
}
|
||||
|
||||
// System-/Diagnose-Info fürs Admin-Panel. Reiner Builder (testbar); die Route hängt
|
||||
// nur die zur Laufzeit ermittelten Versionen dran.
|
||||
export function systemInfo({ version, hugo }) {
|
||||
return {
|
||||
core: { name: 'openbureau-core', version },
|
||||
site: config.site || null,
|
||||
auth: config.auth || 'supabase',
|
||||
admins: config.admins || [],
|
||||
plugins: manager.plugins.map((p) => ({
|
||||
name: p.name, routes: (p.routes || []).length, migrations: (p.migrations || []).length,
|
||||
})),
|
||||
collections: config.collections.map((c) => ({
|
||||
kind: c.kind, label: c.label, statKey: c.statKey || null, fields: (c.fields || []).length,
|
||||
})),
|
||||
hugo,
|
||||
};
|
||||
}
|
||||
|
||||
const system = new Hono();
|
||||
system.use('*', requireAdmin);
|
||||
|
||||
system.get('/', async (c) => c.json(systemInfo({ version: await coreVersion(), hugo: await hugoVersion() })));
|
||||
|
||||
// Update-Check (token-frei): laufende Core-Version (Image, /app/package.json) vs. die
|
||||
// im Repo VENDORTE Version (SITE_DIR/cms/core/api/package.json). Ist die vendorte neuer,
|
||||
// wurde core aktualisiert aber noch nicht neu gebaut → "Rebuild nötig" (Update-Skript).
|
||||
system.get('/update', async (c) => {
|
||||
const running = await coreVersion();
|
||||
const siteDir = process.env.SITE_DIR || '/site';
|
||||
const vendorPath = path.join(siteDir, process.env.CORE_VENDOR_PATH || 'cms/core', 'api/package.json');
|
||||
let vendored = null;
|
||||
try { vendored = JSON.parse(await readFile(vendorPath, 'utf8')).version || null; }
|
||||
catch { /* nicht als Subtree eingebunden → kein Vergleich möglich */ }
|
||||
return c.json({ running, vendored, available: !!(vendored && vendored !== running) });
|
||||
});
|
||||
|
||||
export default system;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../supabase.js';
|
||||
import { requireAdmin, roleOf } from '../auth.js';
|
||||
import { config } from '../config.js';
|
||||
import * as local from '../auth-local.js';
|
||||
|
||||
// Autoren-/Nutzerverwaltung. Mit auth:'local' gegen die User-Datei (DB-los),
|
||||
// sonst über die GoTrue-Admin-API (Service-Key). Nur Admins.
|
||||
const ADMINS = (process.env.ADMIN_EMAILS || '')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
const isLocal = () => config.auth === 'local';
|
||||
|
||||
const users = new Hono();
|
||||
users.use('*', requireAdmin);
|
||||
|
||||
users.get('/', async (c) => {
|
||||
if (isLocal()) {
|
||||
try { return c.json(await local.listUsers(ADMINS)); }
|
||||
catch (e) { return c.json({ error: String(e.message || e) }, 500); }
|
||||
}
|
||||
const { data, error } = await supabase.auth.admin.listUsers();
|
||||
if (error) return c.json({ error: error.message }, 500);
|
||||
const list = (data?.users || []).map((u) => {
|
||||
const role = roleOf(u);
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
created_at: u.created_at,
|
||||
last_sign_in_at: u.last_sign_in_at || null,
|
||||
role,
|
||||
isAdmin: role === 'admin',
|
||||
// Admins aus der .env lassen sich nicht per UI herabstufen.
|
||||
fixedAdmin: ADMINS.includes((u.email || '').toLowerCase()),
|
||||
};
|
||||
});
|
||||
return c.json(list);
|
||||
});
|
||||
|
||||
users.post('/', async (c) => {
|
||||
const { email, password, role } = await c.req.json();
|
||||
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
||||
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
|
||||
if (isLocal()) {
|
||||
try { return c.json({ ok: true, id: await local.createUser({ email, password, role }) }); }
|
||||
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
|
||||
}
|
||||
const payload = { email, password, email_confirm: true };
|
||||
if (role && role !== 'user') payload.app_metadata = { role };
|
||||
const { data, error } = await supabase.auth.admin.createUser(payload);
|
||||
if (error) return c.json({ error: error.message }, 400);
|
||||
return c.json({ ok: true, id: data.user.id });
|
||||
});
|
||||
|
||||
users.put('/:id', async (c) => {
|
||||
const { password, role } = await c.req.json();
|
||||
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
|
||||
if (!password && !role) return c.json({ error: 'Nichts zu ändern' }, 400);
|
||||
if (isLocal()) {
|
||||
try { await local.updateUser(c.req.param('id'), { password, role }); return c.json({ ok: true }); }
|
||||
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
|
||||
}
|
||||
const patch = {};
|
||||
if (password) patch.password = password;
|
||||
if (role) patch.app_metadata = { role };
|
||||
const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), patch);
|
||||
if (error) return c.json({ error: error.message }, 400);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
users.delete('/:id', async (c) => {
|
||||
if (isLocal()) {
|
||||
try { await local.deleteUser(c.req.param('id')); return c.json({ ok: true }); }
|
||||
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
|
||||
}
|
||||
const { error } = await supabase.auth.admin.deleteUser(c.req.param('id'));
|
||||
if (error) return c.json({ error: error.message }, 400);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export default users;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const url = process.env.SUPABASE_URL;
|
||||
const key = process.env.SUPABASE_SERVICE_KEY;
|
||||
|
||||
const opts = { auth: { persistSession: false, autoRefreshToken: false } };
|
||||
|
||||
// Mit Keys: echte Clients. Ohne (DB-loser core, auth:'local'): Clients bleiben
|
||||
// null; die Aufrufer (auth/stats/users) sind null-sicher und nutzen den lokalen
|
||||
// Provider. DB-Plugins (dialog) laufen ohnehin nur mit Supabase. Den Fail-Fast
|
||||
// für auth:'supabase' macht index.js (das kennt die config) — supabase.js bleibt
|
||||
// bewusst config-frei, damit Unit-Tests es ohne CMS_CONFIG importieren können.
|
||||
let _supabase = null;
|
||||
let _supabaseAuth = null;
|
||||
if (url && key) {
|
||||
// Daten-Client: Service-Role-Key, umgeht RLS. NUR für DB-Zugriffe (from/insert/…).
|
||||
// Wichtig: hier niemals signInWithPassword aufrufen — das schaltet den
|
||||
// Authorization-Header des Clients prozessweit auf das User-Token um (SIGNED_IN),
|
||||
// wodurch anschließende Inserts als role=authenticated laufen und an RLS scheitern.
|
||||
_supabase = createClient(url, key, opts);
|
||||
// Eigener Client nur für Auth (Login, Token-Prüfung). Getrennt, damit ein
|
||||
// signInWithPassword den Daten-Client oben nicht „vergiftet". Niemals ins Frontend.
|
||||
_supabaseAuth = createClient(url, key, opts);
|
||||
} else {
|
||||
console.warn('supabase.js: kein SUPABASE_URL/SERVICE_KEY — DB-loser Betrieb (auth: local erwartet).');
|
||||
}
|
||||
|
||||
export const supabase = _supabase;
|
||||
export const supabaseAuth = _supabaseAuth;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { rm, readFile } from 'node:fs/promises';
|
||||
|
||||
// Isolated users file + a known secret BEFORE importing the module (it reads env
|
||||
// at import time). No Supabase needed — this is the DB-less path.
|
||||
const USERS = join(tmpdir(), `cms-users-${process.pid}.json`);
|
||||
process.env.USERS_FILE = USERS;
|
||||
process.env.JWT_SECRET ||= 'test-secret-xyz';
|
||||
|
||||
const local = await import('../src/auth-local.js');
|
||||
const { verify } = await import('hono/jwt');
|
||||
|
||||
test.after(async () => { await rm(USERS, { force: true }); });
|
||||
|
||||
test('create → list → login mints a GoTrue-shaped, verifiable token', async () => {
|
||||
const id = await local.createUser({ email: 'Karim@Example.com', password: 'hunter2', role: 'admin' });
|
||||
assert.ok(id);
|
||||
const list = await local.listUsers(['someone@else.com']);
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0].email, 'Karim@Example.com');
|
||||
assert.equal(list[0].role, 'admin');
|
||||
|
||||
// wrong password rejected, right password accepted (case-insensitive email)
|
||||
assert.equal(await local.login('karim@example.com', 'nope'), null);
|
||||
const res = await local.login('karim@example.com', 'hunter2');
|
||||
assert.ok(res?.access_token);
|
||||
assert.equal(res.user.email, 'Karim@Example.com');
|
||||
|
||||
const claims = await verify(res.access_token, process.env.JWT_SECRET, 'HS256');
|
||||
assert.equal(claims.sub, id);
|
||||
assert.equal(claims.email, 'Karim@Example.com');
|
||||
assert.equal(claims.app_metadata.role, 'admin'); // same shape auth.js/roleOf read
|
||||
assert.ok(claims.exp > Math.floor(Date.now() / 1000));
|
||||
});
|
||||
|
||||
test('passwords are bcrypt-hashed at rest (never plaintext)', async () => {
|
||||
const raw = JSON.parse(await readFile(USERS, 'utf8'));
|
||||
assert.match(raw[0].password, /^\$2[aby]\$/); // bcrypt hash prefix
|
||||
assert.notEqual(raw[0].password, 'hunter2');
|
||||
});
|
||||
|
||||
test('countByRole reflects roles; fixed-admin email forced to admin', async () => {
|
||||
await local.createUser({ email: 'red@x.com', password: 'pw', role: 'editor' });
|
||||
const counts = await local.countByRole([]);
|
||||
assert.equal(counts.total, 2);
|
||||
assert.equal(counts.admin, 1);
|
||||
assert.equal(counts.editor, 1);
|
||||
// an env-admin email overrides the stored role in the listing
|
||||
const forced = await local.listUsers(['red@x.com']);
|
||||
assert.equal(forced.find((u) => u.email === 'red@x.com').role, 'admin');
|
||||
});
|
||||
|
||||
test('update + delete', async () => {
|
||||
const id = await local.createUser({ email: 'tmp@x.com', password: 'pw' });
|
||||
await local.updateUser(id, { role: 'editor' });
|
||||
assert.equal((await local.listUsers([])).find((u) => u.id === id).role, 'editor');
|
||||
await local.deleteUser(id);
|
||||
assert.equal((await local.listUsers([])).some((u) => u.id === id), false);
|
||||
await assert.rejects(() => local.deleteUser('nope'), /nicht gefunden/);
|
||||
});
|
||||
|
||||
test('duplicate email rejected', async () => {
|
||||
await assert.rejects(() => local.createUser({ email: 'red@x.com', password: 'pw' }), /existiert bereits/);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { classify, buildPath, compareEntries } from '../src/collections.js';
|
||||
import openbureau from '../../examples/openbureau.config.js';
|
||||
import kgva from '../../examples/kgva.config.js';
|
||||
|
||||
const ob = openbureau.collections;
|
||||
const kg = kgva.collections;
|
||||
|
||||
// --- openbureau: must reproduce the original hard-coded classify() 1:1 ---
|
||||
|
||||
test('classify (openbureau): Beitrag = archiv/<section>/<slug>', () => {
|
||||
assert.deepEqual(classify('archiv/software/stack.md', ob), { kind: 'beitrag', section: 'software' });
|
||||
assert.deepEqual(classify('archiv/buerofuehrung/x.md', ob), { kind: 'beitrag', section: 'buerofuehrung' });
|
||||
});
|
||||
|
||||
test('classify (openbureau): Library = library/<slug>, section konstant', () => {
|
||||
assert.deepEqual(classify('library/stack.md', ob), { kind: 'biblio', section: 'library' });
|
||||
});
|
||||
|
||||
test('classify (openbureau): _index.md = Rubrik, section = Elternordner|home', () => {
|
||||
assert.deepEqual(classify('_index.md', ob), { kind: 'rubrik', section: 'home' });
|
||||
assert.deepEqual(classify('software/_index.md', ob), { kind: 'rubrik', section: 'software' });
|
||||
});
|
||||
|
||||
test('classify (openbureau): alles andere = Seite (fallback)', () => {
|
||||
assert.deepEqual(classify('manifest.md', ob), { kind: 'seite', section: null });
|
||||
// _index hat Vorrang vor jedem Pfad-Pattern (wie im Original)
|
||||
assert.deepEqual(classify('library/_index.md', ob), { kind: 'rubrik', section: 'library' });
|
||||
// archiv strikt drei-tief: tiefer faellt auf Seite (Original: len !== 3)
|
||||
assert.deepEqual(classify('archiv/software/a/b.md', ob), { kind: 'seite', section: null });
|
||||
});
|
||||
|
||||
test('buildPath (openbureau): aus Pfad-Pattern + Formulardaten', () => {
|
||||
assert.equal(buildPath('beitrag', { section: 'software', slug: 'neu' }, ob), 'archiv/software/neu.md');
|
||||
assert.equal(buildPath('biblio', { slug: 'stack' }, ob), 'library/stack.md');
|
||||
assert.equal(buildPath('seite', { slug: 'impressum' }, ob), 'impressum.md');
|
||||
assert.equal(buildPath('beitrag', { section: 'software', slug: '' }, ob), '');
|
||||
});
|
||||
|
||||
test('compareEntries (openbureau): Reihenfolge Beitrag<Library<Seite<Rubrik', () => {
|
||||
const items = [
|
||||
{ kind: 'rubrik', date: null, title: 'R' },
|
||||
{ kind: 'seite', date: null, title: 'S' },
|
||||
{ kind: 'beitrag', date: '2024-01-01', title: 'B' },
|
||||
{ kind: 'biblio', date: null, title: 'L' },
|
||||
];
|
||||
const sorted = [...items].sort(compareEntries(ob)).map((e) => e.kind);
|
||||
assert.deepEqual(sorted, ['beitrag', 'biblio', 'seite', 'rubrik']);
|
||||
});
|
||||
|
||||
test('compareEntries: gleiche kind -> Datum absteigend, dann Titel', () => {
|
||||
const items = [
|
||||
{ kind: 'beitrag', date: '2023-05-01', title: 'B' },
|
||||
{ kind: 'beitrag', date: '2024-05-01', title: 'A' },
|
||||
{ kind: 'beitrag', date: '2024-05-01', title: 'C' },
|
||||
];
|
||||
const sorted = [...items].sort(compareEntries(ob)).map((e) => e.title);
|
||||
assert.deepEqual(sorted, ['A', 'C', 'B']);
|
||||
});
|
||||
|
||||
// --- kgva: a different content model, same engine ---
|
||||
|
||||
test('classify (kgva): Portfolio / Fallback-Page / Index-Section', () => {
|
||||
assert.deepEqual(classify('portfolio/projekt.md', kg), { kind: 'project', section: 'portfolio' });
|
||||
assert.deepEqual(classify('ueber.md', kg), { kind: 'page', section: null });
|
||||
assert.deepEqual(classify('_index.md', kg), { kind: 'section', section: 'home' });
|
||||
assert.deepEqual(classify('arbeiten/_index.md', kg), { kind: 'section', section: 'arbeiten' });
|
||||
});
|
||||
|
||||
test('buildPath (kgva)', () => {
|
||||
assert.equal(buildPath('project', { slug: 'haus' }, kg), 'portfolio/haus.md');
|
||||
assert.equal(buildPath('page', { slug: 'ueber' }, kg), 'ueber.md');
|
||||
});
|
||||
|
||||
import { contentStats } from '../src/collections.js';
|
||||
|
||||
test('contentStats (openbureau): statKey-Zähler + entwuerfe = Beitrag-Entwürfe', () => {
|
||||
const entries = [
|
||||
{ kind: 'beitrag', draft: false },
|
||||
{ kind: 'beitrag', draft: true },
|
||||
{ kind: 'beitrag', draft: true },
|
||||
{ kind: 'biblio', draft: true }, // Library-Entwurf zählt NICHT als entwuerfe
|
||||
{ kind: 'seite', draft: false },
|
||||
{ kind: 'rubrik', draft: false },
|
||||
];
|
||||
assert.deepEqual(contentStats(entries, ob), {
|
||||
beitraege: 3, entwuerfe: 2, library: 1, rubriken: 1, seiten: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('contentStats (kgva): nur deklarierte statKeys, kein entwuerfe', () => {
|
||||
const entries = [
|
||||
{ kind: 'project', draft: true },
|
||||
{ kind: 'project', draft: false },
|
||||
{ kind: 'page', draft: false }, // page hat keinen statKey
|
||||
{ kind: 'section', draft: false }, // section hat keinen statKey
|
||||
];
|
||||
assert.deepEqual(contentStats(entries, kg), { projects: 2 });
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Stage-5 cutover: index.js/stats.js/publish.js no longer hard-code the dialog;
|
||||
// they go through the shared plugin manager singleton (src/plugins.js), loaded
|
||||
// from config.plugins. This proves the singleton + the rewired route modules
|
||||
// resolve (guards the moved-file import paths) — handlers aren't called, so
|
||||
// dummy supabase/JWT env is enough, as in dialog-plugin.test.js.
|
||||
process.env.CMS_CONFIG ||= fileURLToPath(new URL('../../examples/openbureau.config.js', import.meta.url));
|
||||
process.env.SUPABASE_URL ||= 'http://localhost';
|
||||
process.env.SUPABASE_SERVICE_KEY ||= 'x';
|
||||
process.env.JWT_SECRET ||= 'x';
|
||||
process.env.SITE_DIR ||= '/tmp/nosite';
|
||||
|
||||
const { manager } = await import('../src/plugins.js');
|
||||
|
||||
test('plugins.js: manager loaded from config.plugins (openbureau → dialog)', () => {
|
||||
assert.deepEqual(manager.names(), ['dialog']);
|
||||
assert.equal(manager.has('dialog'), true);
|
||||
});
|
||||
|
||||
test('cutover: stats.js still resolves with the manager dependency', async () => {
|
||||
const stats = (await import('../src/routes/stats.js')).default;
|
||||
assert.equal(typeof stats.fetch, 'function'); // a Hono app
|
||||
});
|
||||
|
||||
test('cutover: publish.js still resolves with the manager dependency', async () => {
|
||||
const publish = (await import('../src/routes/publish.js')).default;
|
||||
assert.equal(typeof publish.fetch, 'function');
|
||||
});
|
||||
|
||||
test('cutover: collectStats exposes dialog under its plugin name', async () => {
|
||||
// Counts hit a dummy supabase → each count() swallows the error and returns 0,
|
||||
// but the SHAPE (the stats.js contract) must hold: { dialog: { forums, … } }.
|
||||
const merged = await manager.collectStats({});
|
||||
assert.deepEqual(merged.dialog, { forums: 0, threads: 0, comments: 0 });
|
||||
const dialog = merged.dialog || { forums: 0, threads: 0, comments: 0 };
|
||||
assert.deepEqual(Object.keys(dialog).sort(), ['comments', 'forums', 'threads']);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
// The dialog modules import supabase.js, which exits without these; dummies are
|
||||
// enough since this test only checks structure/mounting, never calls a handler.
|
||||
process.env.SUPABASE_URL ||= 'http://localhost';
|
||||
process.env.SUPABASE_SERVICE_KEY ||= 'x';
|
||||
process.env.JWT_SECRET ||= 'x';
|
||||
process.env.SITE_DIR ||= '/tmp/nosite';
|
||||
|
||||
const { loadPlugins } = await import('../src/plugin-manager.js');
|
||||
const m = await loadPlugins(['dialog']);
|
||||
|
||||
test('dialog plugin loads with a valid manifest', () => {
|
||||
assert.deepEqual(m.names(), ['dialog']);
|
||||
assert.equal(m.has('dialog'), true);
|
||||
});
|
||||
|
||||
test('dialog: route tiers as in index.js', () => {
|
||||
const routes = m.plugins[0].routes;
|
||||
const pub = routes.filter((r) => r.public).map((r) => `${r.method || 'route'} ${r.path}`);
|
||||
const priv = routes.filter((r) => !r.public).map((r) => `${r.method || 'route'} ${r.path}`);
|
||||
assert.deepEqual(pub, [
|
||||
'get /comments', 'get /forums', 'get /forums/:slug', 'get /recent', 'get /thread', 'post /auth/login',
|
||||
]);
|
||||
assert.deepEqual(priv, [
|
||||
'post /comments', 'delete /comments/:id', 'post /threads', 'route /mod', 'route /admin/forums',
|
||||
]);
|
||||
});
|
||||
|
||||
test('dialog: widget login is rate-limited', () => {
|
||||
const loginRoute = m.plugins[0].routes.find((r) => r.path === '/auth/login');
|
||||
assert.equal(loginRoute.public, true);
|
||||
assert.equal(Array.isArray(loginRoute.use) && loginRoute.use.length, 1);
|
||||
});
|
||||
|
||||
test('dialog: lifecycle hooks + stats present', () => {
|
||||
const p = m.plugins[0];
|
||||
assert.equal(typeof p.onBoot, 'function');
|
||||
assert.equal(typeof p.onPublish, 'function');
|
||||
assert.equal(typeof p.stats, 'function');
|
||||
});
|
||||
|
||||
test('dialog: mounts into the pipeline without throwing', () => {
|
||||
const app = new Hono();
|
||||
const requireAdmin = async (c, next) => next();
|
||||
assert.doesNotThrow(() => {
|
||||
m.mountPublic(app);
|
||||
app.use('/api/*', async (c, next) => next());
|
||||
m.mountPrivate(app, '/api', requireAdmin);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runMigrations } from '../src/migrate.js';
|
||||
import { createManager } from '../src/plugin-manager.js';
|
||||
|
||||
// Dialog modules import supabase.js, which exits without these (loadPlugins for
|
||||
// the real dialog manifest in the last test touches them transitively).
|
||||
process.env.SUPABASE_URL ||= 'http://localhost';
|
||||
process.env.SUPABASE_SERVICE_KEY ||= 'x';
|
||||
process.env.JWT_SECRET ||= 'x';
|
||||
process.env.SITE_DIR ||= '/tmp/nosite';
|
||||
|
||||
// A fake SQL executor over an in-memory schema_migrations set. Records every
|
||||
// non-bookkeeping statement so we can assert what actually ran.
|
||||
function fakeExec(seed = []) {
|
||||
const applied = new Set(seed);
|
||||
const ran = [];
|
||||
const exec = async (text, params) => {
|
||||
if (/^\s*SELECT 1 FROM public\.schema_migrations/i.test(text)) {
|
||||
return applied.has(params[0]) ? [{ '?column?': 1 }] : [];
|
||||
}
|
||||
if (/^\s*INSERT INTO public\.schema_migrations/i.test(text)) { applied.add(params[0]); return []; }
|
||||
if (/^\s*(CREATE TABLE IF NOT EXISTS public\.schema_migrations|BEGIN|COMMIT|ROLLBACK)/i.test(text)) return [];
|
||||
ran.push(text); // the actual migration file SQL
|
||||
return [];
|
||||
};
|
||||
return { exec, ran, applied };
|
||||
}
|
||||
|
||||
test('no declared migrations → no-op, never needs a DB', async () => {
|
||||
const mgr = createManager([{ name: 'kgva-ish' }]); // no migrations
|
||||
const res = await runMigrations(mgr, {}); // no exec, no databaseUrl — must not throw
|
||||
assert.deepEqual(res, { applied: [], skipped: [], pending: false });
|
||||
});
|
||||
|
||||
test('declared migrations but no DATABASE_URL → pending, nothing applied', async () => {
|
||||
const mgr = createManager([{ name: 'p', migrations: ['001.sql'], __dir: new URL('file:///x/') }]);
|
||||
const warns = [];
|
||||
const res = await runMigrations(mgr, { log: { warn: (m) => warns.push(m) } });
|
||||
assert.equal(res.pending, true);
|
||||
assert.deepEqual(res.skipped, ['p/001.sql']);
|
||||
assert.equal(res.applied.length, 0);
|
||||
assert.match(warns[0], /DATABASE_URL fehlt/);
|
||||
});
|
||||
|
||||
test('fresh apply: real dialog migration runs once and is recorded', async () => {
|
||||
const { loadPlugins } = await import('../src/plugin-manager.js');
|
||||
const mgr = await loadPlugins(['dialog']); // real manifest → real 001_dialog.sql url
|
||||
const fx = fakeExec();
|
||||
const res = await runMigrations(mgr, { exec: fx.exec, log: {} });
|
||||
assert.deepEqual(res.applied, ['dialog/001_dialog.sql']);
|
||||
assert.deepEqual(res.skipped, []);
|
||||
assert.equal(fx.ran.length, 1);
|
||||
assert.match(fx.ran[0], /create table if not exists public\.forums/i); // the captured DDL
|
||||
assert.equal(fx.applied.has('dialog/001_dialog.sql'), true);
|
||||
});
|
||||
|
||||
test('idempotent: already-recorded migration is skipped, file SQL not re-run', async () => {
|
||||
const { loadPlugins } = await import('../src/plugin-manager.js');
|
||||
const mgr = await loadPlugins(['dialog']);
|
||||
const fx = fakeExec(['dialog/001_dialog.sql']); // pretend already applied
|
||||
const res = await runMigrations(mgr, { exec: fx.exec, log: {} });
|
||||
assert.deepEqual(res.skipped, ['dialog/001_dialog.sql']);
|
||||
assert.deepEqual(res.applied, []);
|
||||
assert.equal(fx.ran.length, 0); // file SQL never executed again
|
||||
});
|
||||
|
||||
test('a failing migration rolls back and surfaces the id', async () => {
|
||||
const mgr = createManager([{ name: 'boom', migrations: ['x.sql'], __dir: new URL('file:///does-not-exist/') }]);
|
||||
const calls = [];
|
||||
const exec = async (text) => {
|
||||
calls.push(text.trim().split(/\s+/).slice(0, 2).join(' '));
|
||||
return [];
|
||||
};
|
||||
// readFile on the missing url throws before BEGIN; ensure the error names the id.
|
||||
await assert.rejects(() => runMigrations(mgr, { exec, log: {} }), /boom\/x\.sql/);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Hono } from 'hono';
|
||||
import { createManager } from '../src/plugin-manager.js';
|
||||
|
||||
// Tiny sub-app that answers GET / with a fixed text.
|
||||
function mk(text) { const a = new Hono(); a.get('/', (c) => c.text(text)); return a; }
|
||||
|
||||
function fixture() {
|
||||
const state = { booted: false, published: null, previewed: null };
|
||||
const demo = {
|
||||
name: 'demo',
|
||||
routes: [
|
||||
// sub-app routes
|
||||
{ path: '/demo-pub', app: mk('pub'), public: true },
|
||||
{ path: '/demo-priv', app: mk('priv') },
|
||||
{ path: '/demo-adm', app: mk('adm'), admin: true },
|
||||
// handler routes (method + handler)
|
||||
{ method: 'get', path: '/h-pub', handler: (c) => c.text('hpub'), public: true },
|
||||
{ method: 'post', path: '/h-priv', handler: (c) => c.text('hpriv') },
|
||||
// per-route middleware (e.g. a rate limit)
|
||||
{
|
||||
method: 'post', path: '/h-limited', public: true,
|
||||
use: [async (c, next) => (c.req.header('x-pass') ? next() : c.json({ e: 'limited' }, 429))],
|
||||
handler: (c) => c.text('ok'),
|
||||
},
|
||||
],
|
||||
onBoot: async () => { state.booted = true; },
|
||||
onPublish: async (_ctx, p) => { state.published = p.path; },
|
||||
onPreview: async (_ctx, p) => { state.previewed = p.path; },
|
||||
stats: async () => ({ widgets: 3 }),
|
||||
migrations: ['001_demo.sql'],
|
||||
};
|
||||
return { demo, state };
|
||||
}
|
||||
|
||||
// Build the same pipeline as index.js: public → requireAuth → private(+admin).
|
||||
function buildApp(m) {
|
||||
const app = new Hono();
|
||||
m.mountPublic(app);
|
||||
app.use('/api/*', async (c, next) => (c.req.header('x-auth') ? next() : c.json({ error: 'no auth' }, 401)));
|
||||
const requireAdmin = async (c, next) => (c.req.header('x-admin') ? next() : c.json({ error: 'no admin' }, 403));
|
||||
m.mountPrivate(app, '/api', requireAdmin);
|
||||
return app;
|
||||
}
|
||||
|
||||
test('manager: names / has', () => {
|
||||
const { demo } = fixture();
|
||||
const m = createManager([demo]);
|
||||
assert.deepEqual(m.names(), ['demo']);
|
||||
assert.equal(m.has('demo'), true);
|
||||
assert.equal(m.has('nope'), false);
|
||||
});
|
||||
|
||||
test('manager: public routes reachable WITHOUT auth (sub-app + handler)', async () => {
|
||||
const app = buildApp(createManager([fixture().demo]));
|
||||
const sub = await app.request('/api/demo-pub');
|
||||
assert.equal(sub.status, 200); assert.equal(await sub.text(), 'pub');
|
||||
const h = await app.request('/api/h-pub');
|
||||
assert.equal(h.status, 200); assert.equal(await h.text(), 'hpub');
|
||||
});
|
||||
|
||||
test('manager: private routes require auth (sub-app + handler)', async () => {
|
||||
const app = buildApp(createManager([fixture().demo]));
|
||||
assert.equal((await app.request('/api/demo-priv')).status, 401);
|
||||
assert.equal((await app.request('/api/h-priv', { method: 'POST' })).status, 401);
|
||||
const ok = await app.request('/api/h-priv', { method: 'POST', headers: { 'x-auth': '1' } });
|
||||
assert.equal(ok.status, 200); assert.equal(await ok.text(), 'hpriv');
|
||||
});
|
||||
|
||||
test('manager: admin route guarded by requireAdmin', async () => {
|
||||
const app = buildApp(createManager([fixture().demo]));
|
||||
assert.equal((await app.request('/api/demo-adm', { headers: { 'x-auth': '1' } })).status, 403);
|
||||
const ok = await app.request('/api/demo-adm', { headers: { 'x-auth': '1', 'x-admin': '1' } });
|
||||
assert.equal(ok.status, 200); assert.equal(await ok.text(), 'adm');
|
||||
});
|
||||
|
||||
test('manager: per-route use middleware runs (rate-limit style)', async () => {
|
||||
const app = buildApp(createManager([fixture().demo]));
|
||||
assert.equal((await app.request('/api/h-limited', { method: 'POST' })).status, 429);
|
||||
const ok = await app.request('/api/h-limited', { method: 'POST', headers: { 'x-pass': '1' } });
|
||||
assert.equal(ok.status, 200); assert.equal(await ok.text(), 'ok');
|
||||
});
|
||||
|
||||
test('manager: lifecycle hooks run', async () => {
|
||||
const { demo, state } = fixture();
|
||||
const m = createManager([demo]);
|
||||
await m.runBoot({});
|
||||
await m.runPublish({}, { path: 'a.md' });
|
||||
await m.runPreview({}, { path: 'b.md' });
|
||||
assert.equal(state.booted, true);
|
||||
assert.equal(state.published, 'a.md');
|
||||
assert.equal(state.previewed, 'b.md');
|
||||
});
|
||||
|
||||
test('manager: stats merged under plugin name', async () => {
|
||||
const m = createManager([fixture().demo]);
|
||||
assert.deepEqual(await m.collectStats({}), { demo: { widgets: 3 } });
|
||||
});
|
||||
|
||||
test('manager: declared migrations surfaced', () => {
|
||||
const m = createManager([fixture().demo]);
|
||||
assert.deepEqual(m.migrations(), [{ plugin: 'demo', file: '001_demo.sql', url: null }]);
|
||||
});
|
||||
|
||||
test('manager: empty plugin set is a no-op', async () => {
|
||||
const m = createManager([]);
|
||||
assert.deepEqual(m.names(), []);
|
||||
assert.deepEqual(await m.collectStats({}), {});
|
||||
assert.deepEqual(m.migrations(), []);
|
||||
const app = buildApp(m);
|
||||
assert.equal((await app.request('/api/anything', { headers: { 'x-auth': '1' } })).status, 404);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
process.env.CMS_CONFIG ||= fileURLToPath(new URL('../../examples/openbureau.config.js', import.meta.url));
|
||||
process.env.SUPABASE_URL ||= 'http://localhost';
|
||||
process.env.SUPABASE_SERVICE_KEY ||= 'x';
|
||||
process.env.JWT_SECRET ||= 'x';
|
||||
process.env.SITE_DIR ||= '/tmp/nosite';
|
||||
|
||||
const { systemInfo } = await import('../src/routes/system.js');
|
||||
|
||||
test('systemInfo reflects core version, plugins and content model', () => {
|
||||
const info = systemInfo({ version: '0.6.0', hugo: '0.161.1+extended' });
|
||||
assert.equal(info.core.name, 'openbureau-core');
|
||||
assert.equal(info.core.version, '0.6.0');
|
||||
assert.equal(info.site, 'openbureau');
|
||||
assert.equal(info.auth, 'supabase'); // openbureau.config.js sets auth: 'supabase'
|
||||
assert.equal(info.hugo, '0.161.1+extended');
|
||||
assert.deepEqual(info.admins, ['karim@gabrielevarano.ch']);
|
||||
// dialog plugin is active with its routes + the one migration
|
||||
const dialog = info.plugins.find((p) => p.name === 'dialog');
|
||||
assert.ok(dialog, 'dialog plugin listed');
|
||||
assert.ok(dialog.routes > 0);
|
||||
assert.equal(dialog.migrations, 1);
|
||||
// content model surfaced from the config collections
|
||||
assert.deepEqual(info.collections.map((c) => c.kind).sort(), ['beitrag', 'biblio', 'rubrik', 'seite']);
|
||||
const beitrag = info.collections.find((c) => c.kind === 'beitrag');
|
||||
assert.equal(beitrag.label, 'Beiträge');
|
||||
assert.ok(beitrag.fields > 0);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// karimgabrielevarano.xyz — second consumer of openbureau-core.
|
||||
// No dialog plugin; a portfolio-shaped content model.
|
||||
export default {
|
||||
site: 'kgva',
|
||||
auth: 'local', // DB-less: file-based users + self-signed JWTs (no Supabase/Postgres)
|
||||
admins: ['karim@gabrielevarano.ch'],
|
||||
plugins: [],
|
||||
|
||||
collections: [
|
||||
{
|
||||
kind: 'project', label: 'Portfolio', order: 0,
|
||||
path: 'portfolio/:slug',
|
||||
statKey: 'projects',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'slug', type: 'slug' },
|
||||
{ name: 'date', type: 'date' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'thumbnail', type: 'image' },
|
||||
{ name: 'studies', type: 'string', hint: '/studies/hslu/ba/semester_05' },
|
||||
{ name: 'schools', type: 'list' },
|
||||
{ name: 'degrees', type: 'list' },
|
||||
{ name: 'semesters', type: 'list' },
|
||||
{ name: 'video', type: 'string', hint: '/media/x.mp4' },
|
||||
{ name: 'images', type: 'list', of: { src: 'image', name: 'string' } },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'page', label: 'Pages', order: 1, fallback: true,
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'description', type: 'string' },
|
||||
{ name: 'layout', type: 'string' },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'section', label: 'Sections', order: 2, index: true,
|
||||
fields: [
|
||||
{ name: 'title', type: 'string' },
|
||||
{ name: 'description', type: 'string' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
// openbureau site — reproduces the current hard-coded content model 1:1.
|
||||
// Once the engine is config-driven, openbureau consumes openbureau-core with
|
||||
// exactly this config and behaves as today.
|
||||
export default {
|
||||
site: 'openbureau',
|
||||
admins: ['karim@gabrielevarano.ch'],
|
||||
plugins: ['dialog'], // comment/forum subsystem (library ↔ threads sync)
|
||||
|
||||
collections: [
|
||||
{
|
||||
kind: 'beitrag', label: 'Beiträge', order: 0,
|
||||
path: 'archiv/:section/:slug',
|
||||
sections: ['buerofuehrung', 'software', 'theorie'],
|
||||
statKey: 'beitraege',
|
||||
draftStatKey: 'entwuerfe', // Beitrag-Entwürfe als eigener Zähler (Dashboard)
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'section', type: 'select', options: ['buerofuehrung', 'software', 'theorie'] },
|
||||
{ name: 'slug', type: 'slug' },
|
||||
{ name: 'date', type: 'date' },
|
||||
{ name: 'weight', type: 'number' },
|
||||
{ name: 'color', type: 'color' },
|
||||
{ name: 'layout', type: 'select', options: ['text', 'image', 'icon'], default: 'text' },
|
||||
{ name: 'tags', type: 'list' },
|
||||
{ name: 'summary', type: 'text' },
|
||||
{ name: 'cover_image', type: 'image' },
|
||||
{ name: 'authors', type: 'list' },
|
||||
{ name: 'toc', type: 'bool' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'biblio', label: 'Library', order: 1,
|
||||
path: 'library/:slug',
|
||||
statKey: 'library',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'slug', type: 'slug' },
|
||||
{ name: 'date', type: 'date' },
|
||||
{ name: 'tags', type: 'list' },
|
||||
{ name: 'summary', type: 'text' },
|
||||
{ name: 'cover_image', type: 'image' },
|
||||
{ name: 'external', type: 'string' },
|
||||
{ name: 'group', type: 'string' },
|
||||
{ name: 'authors', type: 'list' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'rubrik', label: 'Rubriken', order: 3, index: true,
|
||||
statKey: 'rubriken',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'color', type: 'color' },
|
||||
{ name: 'layout', type: 'string' },
|
||||
{ name: 'weight', type: 'number' },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'seite', label: 'Seiten', order: 2, fallback: true,
|
||||
statKey: 'seiten',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'layout', type: 'string' },
|
||||
{ name: 'toc', type: 'bool' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -146,7 +146,10 @@ services:
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
cms:
|
||||
build:
|
||||
context: .
|
||||
# openbureau consumes openbureau-core, vendored via git subtree at cms/core.
|
||||
# Build context = core's root so core's own Dockerfile picks up core/admin +
|
||||
# core/api (the generic engine), not this site's files.
|
||||
context: ./core
|
||||
dockerfile: api/Dockerfile
|
||||
args:
|
||||
# Browser-seitig (Admin-SPA, zur Build-Zeit): öffentliche Supabase-URL.
|
||||
@@ -169,6 +172,11 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||
SITE_DIR: /site
|
||||
# Tells core which content model + plugins this site has (schema-driven engine).
|
||||
# Lives in the mounted repo (/site = repo root). DATABASE_URL is intentionally
|
||||
# unset: the stack's `migrate` service owns the schema (db/schema.sql incl. the
|
||||
# dialog tables), so core's plugin migration runner stays a no-op here.
|
||||
CMS_CONFIG: /site/cms/openbureau.config.js
|
||||
PORT: 3000
|
||||
GIT_PUBLISH: ${GIT_PUBLISH:-false}
|
||||
GIT_REMOTE: ${GIT_REMOTE:-origin}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// openbureau site config — what this site's content model + plugins are.
|
||||
// Consumed by openbureau-core (vendored at cms/core) via CMS_CONFIG. Reproduces
|
||||
// the previously hard-coded content model 1:1 (proven by core's collections test).
|
||||
export default {
|
||||
site: 'openbureau',
|
||||
auth: 'supabase', // GoTrue/Supabase login (the stack provides it); core default
|
||||
admins: ['karim@gabrielevarano.ch'],
|
||||
plugins: ['dialog'], // comment/forum subsystem (library ↔ threads sync)
|
||||
|
||||
collections: [
|
||||
{
|
||||
kind: 'beitrag', label: 'Beiträge', order: 0,
|
||||
path: 'archiv/:section/:slug',
|
||||
sections: ['buerofuehrung', 'software', 'theorie'],
|
||||
statKey: 'beitraege',
|
||||
draftStatKey: 'entwuerfe', // Beitrag-Entwürfe als eigener Zähler (Dashboard)
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'section', type: 'select', options: ['buerofuehrung', 'software', 'theorie'] },
|
||||
{ name: 'slug', type: 'slug' },
|
||||
{ name: 'date', type: 'date' },
|
||||
{ name: 'weight', type: 'number' },
|
||||
{ name: 'color', type: 'color' },
|
||||
{ name: 'layout', type: 'select', options: ['text', 'image', 'icon'], default: 'text' },
|
||||
{ name: 'tags', type: 'list' },
|
||||
{ name: 'summary', type: 'text' },
|
||||
{ name: 'cover_image', type: 'image' },
|
||||
{ name: 'authors', type: 'list' },
|
||||
{ name: 'toc', type: 'bool' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'biblio', label: 'Library', order: 1,
|
||||
path: 'library/:slug',
|
||||
statKey: 'library',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'slug', type: 'slug' },
|
||||
{ name: 'date', type: 'date' },
|
||||
{ name: 'tags', type: 'list' },
|
||||
{ name: 'summary', type: 'text' },
|
||||
{ name: 'cover_image', type: 'image' },
|
||||
{ name: 'external', type: 'string' },
|
||||
{ name: 'group', type: 'string' },
|
||||
{ name: 'authors', type: 'list' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'rubrik', label: 'Rubriken', order: 3, index: true,
|
||||
statKey: 'rubriken',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'color', type: 'color' },
|
||||
{ name: 'layout', type: 'string' },
|
||||
{ name: 'weight', type: 'number' },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'seite', label: 'Seiten', order: 2, fallback: true,
|
||||
statKey: 'seiten',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'layout', type: 'string' },
|
||||
{ name: 'toc', type: 'bool' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: "Archiv"
|
||||
description: "Fertige Texte des Büros, nach Thema geordnet."
|
||||
---
|
||||
|
||||
Fertige Texte, nach Thema geordnet. Das **Journal** auf der Startseite zeigt dieselben Inhalte chronologisch.
|
||||
+1
-1
@@ -11,7 +11,7 @@ Offen zu arbeiten heißt nicht, alles zu verschenken. Es heißt, die Grundlagen
|
||||
|
||||
Die rechtliche Absicherung dafür sind Lizenzen. Inhalte auf OPENBUREAU stehen unter CC BY-SA,[^ccbysa] der Code überwiegend unter AGPL oder MIT.[^agpl] Beide sorgen dafür, dass Offenheit weitergegeben wird, statt verloren zu gehen.
|
||||
|
||||
Verwandt: Der [Werkzeug-Stack](/library/software/stack/) zeigt, womit wir das konkret tun.
|
||||
Verwandt: Der [Werkzeug-Stack](/archiv/software/stack/) zeigt, womit wir das konkret tun.
|
||||
|
||||
[^ccbysa]: Creative Commons, *Attribution-ShareAlike 4.0 International* (CC BY-SA 4.0), <https://creativecommons.org/licenses/by-sa/4.0/>.
|
||||
[^agpl]: Free Software Foundation, *GNU Affero General Public License v3.0*, 2007.
|
||||
+1
-1
@@ -14,4 +14,4 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor i
|
||||
|
||||
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.
|
||||
|
||||
Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet — [Werkzeuge](/library/werkzeuge).
|
||||
Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet — [Werkzeuge](/archiv/werkzeuge).
|
||||
+1
-1
@@ -7,7 +7,7 @@ color: yuyake
|
||||
layout: text
|
||||
---
|
||||
|
||||
Die Werkzeuge des Büros sind keine Inseln, sondern eine Kette: [DOSSIER](/library/software/dossier/) hält die Projektdaten, [RAPPORT](/library/software/rapport/) erzeugt Berichte daraus, und diese Site veröffentlicht, was öffentlich sein soll.
|
||||
Die Werkzeuge des Büros sind keine Inseln, sondern eine Kette: [DOSSIER](/archiv/software/dossier/) hält die Projektdaten, [RAPPORT](/archiv/software/rapport/) erzeugt Berichte daraus, und diese Site veröffentlicht, was öffentlich sein soll.
|
||||
|
||||
Der Build ist bewusst banal — ein Befehl:[^hugo]
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ color: kusa
|
||||
layout: text
|
||||
---
|
||||
|
||||
Die Kiste aus dem [ersten Teil](/library/software/server-im-eigenen-haus/) muss man nicht streicheln können, um sie zu verstehen. Es genügt ein Bild: Proxmox macht aus einem Rechner ein Mehrfamilienhaus. Das Haus ist die Maschine, die Wohnungen sind die Container, und in jeder Wohnung lebt genau ein Dienst — die Website, die Zeiterfassung, der Dateispeicher. Niemand stört den anderen, jeder hat seine eigene Tür, und zieht eine Partei aus, bleiben die übrigen, wo sie sind.
|
||||
Die Kiste aus dem [ersten Teil](/archiv/software/server-im-eigenen-haus/) muss man nicht streicheln können, um sie zu verstehen. Es genügt ein Bild: Proxmox macht aus einem Rechner ein Mehrfamilienhaus. Das Haus ist die Maschine, die Wohnungen sind die Container, und in jeder Wohnung lebt genau ein Dienst — die Website, die Zeiterfassung, der Dateispeicher. Niemand stört den anderen, jeder hat seine eigene Tür, und zieht eine Partei aus, bleiben die übrigen, wo sie sind.
|
||||
|
||||
Dieser Text zeigt, wie man das Haus baut und die erste Wohnung bezieht. Er setzt keine Erfahrung mit Servern voraus, nur die Bereitschaft, einen Befehl abzutippen und zu lesen, was er antwortet.
|
||||
|
||||
+1
-1
@@ -33,4 +33,4 @@ Bleibt die unbequeme Seite, und sie gehört in jeden ehrlichen Text dieser Art:
|
||||
|
||||
Tragbar finden wir das aus zwei Gründen. Der Aufwand ist kleiner, als er klingt, sobald die Handgriffe automatisiert sind — einen neuen Dienst aufzusetzen ist bei uns ein einziger Befehl und kein verlorener Nachmittag. Und die Kontrolle ist den Rest wert: Lieber für ein Backup geradestehen, das man versteht, als sich auf eines verlassen, das man nie gesehen hat.
|
||||
|
||||
Wie die Kiste im Schrank konkret eingerichtet ist — die Maschine, die Container, die Befehle, mit denen ein Dienst in Minuten steht — steht im zweiten Teil: [Proxmox, Schritt für Schritt](/library/software/proxmox-schritt-fuer-schritt/).
|
||||
Wie die Kiste im Schrank konkret eingerichtet ist — die Maschine, die Container, die Befehle, mit denen ein Dienst in Minuten steht — steht im zweiten Teil: [Proxmox, Schritt für Schritt](/archiv/software/proxmox-schritt-fuer-schritt/).
|
||||
@@ -28,7 +28,7 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor i
|
||||
|
||||
## Lorem Ipsum IV
|
||||
|
||||
- **[DOSSIER](/library/software/dossier/)** — lorem ipsum.
|
||||
- **[RAPPORT](/library/software/rapport/)** — dolor sit amet.
|
||||
- **[DOSSIER](/archiv/software/dossier/)** — lorem ipsum.
|
||||
- **[RAPPORT](/archiv/software/rapport/)** — dolor sit amet.
|
||||
|
||||
— sed ut perspiciatis unde omnis iste natus error sit voluptatem.
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
title: "Datenschutz"
|
||||
toc: false
|
||||
showreadingtime: false
|
||||
---
|
||||
|
||||
OPENBUREAU ist selbst-gehostet — ohne Werbung, ohne Tracker, ohne Analyse-Dienste Dritter. So wenig Daten wie möglich zu erheben, ist Teil der Idee.
|
||||
|
||||
## Verantwortlich
|
||||
|
||||
Karim Varano, Fluhmühlerain 1, 6015 Luzern.
|
||||
Kontakt: [karim@gabrielevarano.ch](mailto:karim@gabrielevarano.ch)
|
||||
|
||||
## Beim Besuch der Seite
|
||||
|
||||
Beim Aufruf werden technische Zugriffsdaten (IP-Adresse, Zeitpunkt, aufgerufene Seite, Browsertyp) kurzzeitig in Server-Logs erfasst — ausschließlich für Betrieb und Sicherheit. Diese Daten werden nicht mit anderen Quellen zusammengeführt, nicht für Werbung verwendet und nicht an Dritte weitergegeben.
|
||||
|
||||
Es kommen **keine** Tracking-Cookies, **keine** Analyse-Werkzeuge (etwa Google Analytics) und **keine** Werbenetzwerke zum Einsatz.
|
||||
|
||||
## Dialog (Mitschreiben)
|
||||
|
||||
Lesen ist anonym. Wer im [Dialog](/dialog/) mitschreibt, legt ein Konto an: dafür werden eine E-Mail-Adresse und ein angezeigter Name gespeichert. Deine Wortmeldungen erscheinen mit diesem Namen öffentlich. Nach dem Login wird ein Sitzungs-Token lokal in deinem Browser (localStorage) abgelegt — kein serverseitiges Tracking.
|
||||
|
||||
Diese Daten liegen auf eigener Infrastruktur in Luzern (self-hosted) und werden nicht an Dritte weitergegeben.
|
||||
|
||||
## Deine Rechte
|
||||
|
||||
Nach dem Schweizer Datenschutzgesetz (revDSG) hast du das Recht auf Auskunft, Berichtigung und Löschung deiner Daten — eine kurze Mail genügt. Beschwerden kannst du beim Eidgenössischen Datenschutz- und Öffentlichkeitsbeauftragten (EDÖB) einreichen.
|
||||
|
||||
## Offenheit
|
||||
|
||||
Die Seite ist quelloffen; der Code ist einsehbar. Zu Lizenzen und Technik siehe [Colophon](/colophon/).
|
||||
+28
-2
@@ -1,7 +1,9 @@
|
||||
---
|
||||
title: "Kontakt / Impressum"
|
||||
title: "Impressum"
|
||||
toc: false
|
||||
showreadingtime: false
|
||||
aliases:
|
||||
- /datenschutz/
|
||||
---
|
||||
|
||||
## Kontakt
|
||||
@@ -10,10 +12,34 @@ showreadingtime: false
|
||||
|
||||
Oder direkt im [Dialog](/dialog/).
|
||||
|
||||
## Impressum
|
||||
## Verantwortlich
|
||||
|
||||
Karim Varano\
|
||||
Fluhmühlerain 1\
|
||||
6015 Luzern
|
||||
|
||||
Privatperson
|
||||
|
||||
## Datenschutz
|
||||
|
||||
OPENBUREAU ist selbst-gehostet — ohne Werbung, ohne Tracker, ohne Analyse-Dienste Dritter. So wenig Daten wie möglich zu erheben, ist Teil der Idee.
|
||||
|
||||
### Beim Besuch der Seite
|
||||
|
||||
Beim Aufruf werden technische Zugriffsdaten (IP-Adresse, Zeitpunkt, aufgerufene Seite, Browsertyp) kurzzeitig in Server-Logs erfasst — ausschließlich für Betrieb und Sicherheit. Diese Daten werden nicht mit anderen Quellen zusammengeführt, nicht für Werbung verwendet und nicht an Dritte weitergegeben.
|
||||
|
||||
Es kommen **keine** Tracking-Cookies, **keine** Analyse-Werkzeuge (etwa Google Analytics) und **keine** Werbenetzwerke zum Einsatz.
|
||||
|
||||
### Dialog (Mitschreiben)
|
||||
|
||||
Lesen ist anonym. Wer im [Dialog](/dialog/) mitschreibt, legt ein Konto an: dafür werden eine E-Mail-Adresse und ein angezeigter Name gespeichert. Deine Wortmeldungen erscheinen mit diesem Namen öffentlich. Nach dem Login wird ein Sitzungs-Token lokal in deinem Browser (localStorage) abgelegt — kein serverseitiges Tracking.
|
||||
|
||||
Diese Daten liegen auf eigener Infrastruktur in Luzern (self-hosted) und werden nicht an Dritte weitergegeben.
|
||||
|
||||
### Deine Rechte
|
||||
|
||||
Nach dem Schweizer Datenschutzgesetz (revDSG) hast du das Recht auf Auskunft, Berichtigung und Löschung deiner Daten — eine kurze Mail genügt. Beschwerden kannst du beim Eidgenössischen Datenschutz- und Öffentlichkeitsbeauftragten (EDÖB) einreichen.
|
||||
|
||||
### Offenheit
|
||||
|
||||
Die Seite ist quelloffen; der Code ist einsehbar. Zu Lizenzen und Technik siehe [Colophon](/colophon/).
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: "Library"
|
||||
description: "Die Bibliothek von OPENBUREAU — Texte, Notizen, Recherchen."
|
||||
summary: "Notizen zu Abläufen, Begriffen und Werkzeugen des Büros."
|
||||
---
|
||||
|
||||
Die Library ist die Sammlung. Alles, was gelesen und geschrieben wird, lebt hier.
|
||||
Texte werden thematisch organisiert; das **Journal** auf der Startseite zeigt dieselben Inhalte chronologisch.
|
||||
Notizen zu Abläufen, Begriffen und Werkzeugen aus dem Büroalltag. Fertige Texte stehen im [Archiv](/archiv/). Ergänzungen im CMS oder direkt im Repository.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: "Dateiablage & Benennung"
|
||||
group: "Konventionen"
|
||||
summary: "Wie Projektdateien heissen, damit man sie in fünf Jahren noch findet."
|
||||
toc: true
|
||||
---
|
||||
|
||||
Eine Konvention ist nur dann eine, wenn sich alle daran halten. Dies ist ein Vorschlag, kein Gesetz — verbessern erwünscht.
|
||||
|
||||
## Projektordner
|
||||
|
||||
Jedes Projekt liegt unter `Projekte/JJJJ_Nummer_Kurzname/`, z. B. `2026_014_Mehrfamilienhaus-Seeblick/`. Das Jahr vorne sortiert chronologisch, die Nummer ist eindeutig, der Kurzname macht es lesbar.
|
||||
|
||||
## Dateinamen
|
||||
|
||||
`JJMMTT_Projekt_Inhalt_vNN` — Datum zuerst (sortiert sich selbst), dann was es ist, dann die Version:
|
||||
|
||||
- `260604_Seeblick_Grundriss-EG_v03.pdf`
|
||||
- `260604_Seeblick_Kostenschaetzung_v01.xlsx`
|
||||
|
||||
Keine Umlaute, keine Leerzeichen, keine Sonderzeichen — Bindestrich trennt Wörter, Unterstrich trennt Felder.
|
||||
|
||||
## Versionen
|
||||
|
||||
`vNN` zählt hoch, nichts wird überschrieben. Die jeweils gültige Fassung bekommt keinen Sonderstatus im Namen — das erledigt das Datum. Wer mit Git arbeitet, lässt die Versionsnummer weg und vertraut der Historie.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
title: "Typus"
|
||||
group: "Begriffe"
|
||||
summary: "Ein Prinzip, das viele Werke begründet — nicht die Vorlage zum Kopieren."
|
||||
---
|
||||
|
||||
Der **Typus** ist nicht das fertige Vorbild, sondern das zugrunde liegende Prinzip einer Bauaufgabe: das, was eine Markthalle zur Markthalle macht, unabhängig von Ort, Material und Epoche. Vom *Modell* unterscheidet er sich darin, dass man ihn nicht kopiert, sondern gegen ihn entwirft.[^quatremere]
|
||||
|
||||
Fürs Büro ist der Typus ein Werkzeug der Ökonomie: Wer den Typus einer Aufgabe kennt, beginnt nicht bei null, sondern variiert bewusst — und kann die eigenen Entscheidungen begründen.[^moneo]
|
||||
|
||||
Ausführlicher im Archiv: [Typus und Modell](/archiv/theorie/muster-typologie-fussnoten/).
|
||||
|
||||
[^quatremere]: Antoine-Chrysostome Quatremère de Quincy, *Encyclopédie méthodique. Architecture*, Bd. 3, Paris 1825, Stichwort „Type".
|
||||
[^moneo]: Rafael Moneo, „On Typology", in: *Oppositions* 13 (1978), S. 22–45.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: "Wie die Library funktioniert"
|
||||
group: "Werkstatt"
|
||||
summary: "Kleine Seiten, klare Titel, viele Verweise."
|
||||
toc: true
|
||||
---
|
||||
|
||||
Die Library ist kein Lexikon, das jemand fertigstellt, sondern ein gemeinsames Gedächtnis, das beim Arbeiten entsteht. Ein paar Konventionen halten sie übersichtlich.
|
||||
|
||||
## Eine Seite, ein Begriff
|
||||
|
||||
Lieber viele kleine Seiten als wenige grosse. Eine Seite behandelt einen Begriff, einen Handgriff, eine Entscheidung. Passt etwas nicht mehr auf eine Bildschirmseite, wird es meist zwei Themen sein.
|
||||
|
||||
## Verweise
|
||||
|
||||
Seiten verweisen mit gewöhnlichen Markdown-Links aufeinander — `[Typus](/library/typus/)` — und gerne auch ins [Archiv](/archiv/), wenn ein Gedanke dort ausführlicher steht. Verlinken ist die eigentliche Arbeit: Eine Notiz, auf die nichts zeigt, findet niemand.
|
||||
|
||||
## Gruppen
|
||||
|
||||
Das Feld `group` im Frontmatter sortiert eine Seite in einen Bereich der Übersicht — z. B. `group: "Begriffe"`. Seiten ohne Gruppe landen unter „Allgemein". Mehr Struktur braucht es selten.
|
||||
|
||||
## Bearbeiten
|
||||
|
||||
Jede Seite hat unten einen **bearbeiten**-Link, der direkt ins Repository führt. Wer lieber im Redaktions-Editor arbeitet, legt eine Seite vom Typ *Library* an und füllt Titel, Gruppe und Text.
|
||||
@@ -69,15 +69,13 @@ menus:
|
||||
- name: LIBRARY
|
||||
pageRef: /library
|
||||
weight: 20
|
||||
- name: MANIFEST
|
||||
pageRef: /manifest
|
||||
weight: 30
|
||||
- name: ARCHIV
|
||||
pageRef: /archiv
|
||||
weight: 25
|
||||
- name: DIALOG
|
||||
pageRef: /dialog
|
||||
weight: 40
|
||||
- name: CODE
|
||||
url: https://git.openbureau.ch
|
||||
weight: 50
|
||||
# MANIFEST + CODE stehen im Footer (schlankeres Hauptmenü).
|
||||
|
||||
params:
|
||||
# Öffentliche Gitea-Repo-URL (für Provenance: Version → Commit, Verlauf).
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{{ end }}
|
||||
{{/* Byline + Meta nur bei Library-Beiträgen — Seiten wie Manifest,
|
||||
Kontakt, Spenden brauchen weder Autor noch „Aktualisiert am". */}}
|
||||
{{ if eq .Section "library" }}
|
||||
{{ if eq .Section "archiv" }}
|
||||
{{ $author := .Params.author | default site.Params.author.name }}
|
||||
{{ $aslug := urlize $author }}
|
||||
{{ if not .Params.author }}{{ with index site.Data.authors site.Params.author.email }}{{ with .slug }}{{ $aslug = . }}{{ end }}{{ end }}{{ end }}
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
{{/* Tags: bei Seiten (nicht-Library) wie bisher unter dem Text. Bei Library
|
||||
wandern sie in die Aktionsreihe (rechts neben Dialog) im Partial. */}}
|
||||
{{ if ne .Section "library" }}
|
||||
{{ if ne .Section "archiv" }}
|
||||
{{- with .Params.tags }}
|
||||
<ul class="tag-pills" aria-label="Tags">
|
||||
{{- range . -}}<li><a href="/tags/{{ . | urlize }}/">{{ . }}</a></li>{{- end -}}
|
||||
@@ -71,7 +71,7 @@
|
||||
{{ end }}
|
||||
|
||||
{{/* Artikel-Fuß (zitieren, Dialog, Tags, Versionen) — nur bei Library. */}}
|
||||
{{ if eq .Section "library" }}
|
||||
{{ if eq .Section "archiv" }}
|
||||
{{ partial "provenance.html" . }}
|
||||
<script src="/version-history.js"></script>
|
||||
{{ end }}
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<nav class="footer-links" aria-label="Footer">
|
||||
<a href="/manifest/">Manifest</a>
|
||||
<a href="/colophon/">Colophon</a>
|
||||
<a href="/impressum/">Kontakt / Impressum</a>
|
||||
<a href="/datenschutz/">Datenschutz</a>
|
||||
<a href="/impressum/">Impressum</a>
|
||||
<a href="https://git.openbureau.ch">Code</a>
|
||||
<a href="/index.xml">RSS</a>
|
||||
<a href="/spenden/">Spenden</a>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
{{ define "main" }}
|
||||
{{ if eq .Path "/archiv" }}
|
||||
{{/* Archiv-Übersicht: Umschalter Kategorie ↔ Jahr */}}
|
||||
<div class="collection" style="--section-color: var(--palette-kusa)">
|
||||
<h1 class="collection-title">{{ .Title }}</h1>
|
||||
<div class="collection-inner">
|
||||
{{ .Content }}
|
||||
|
||||
<div class="archiv-toggle" role="group" aria-label="Sortierung">
|
||||
<button type="button" data-mode="cat" class="is-active">nach Kategorie</button>
|
||||
<button type="button" data-mode="year">nach Jahr</button>
|
||||
</div>
|
||||
|
||||
{{/* Ansicht nach Kategorie — Jahr hinter dem Beitrag, je Rubrik die letzten 10 */}}
|
||||
<div class="archiv-view" data-view="cat">
|
||||
<section class="atlas">
|
||||
{{ range .Sections.ByWeight }}
|
||||
{{ $section := path.Base .RelPermalink }}
|
||||
<article class="atlas-section" data-section="{{ $section }}">
|
||||
<h2><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
|
||||
{{ with .Params.description }}<p class="text-muted">{{ . }}</p>{{ end }}
|
||||
<ul class="atlas-list">
|
||||
{{ range first 10 .RegularPages.ByDate.Reverse }}
|
||||
<li>
|
||||
<a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
|
||||
<span class="list-meta text-muted"> · {{ .Date.Format "2006" }}</span>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ if gt (len .RegularPages) 10 }}
|
||||
<p class="more"><a href="{{ .RelPermalink }}">alle in {{ .Title }} →</a></p>
|
||||
{{ end }}
|
||||
</article>
|
||||
{{ end }}
|
||||
|
||||
{{ with site.Taxonomies.tags }}
|
||||
<article class="atlas-tags">
|
||||
<h2>Tags</h2>
|
||||
<ul class="tag-cloud">
|
||||
{{ range $name, $taxonomy := . }}
|
||||
<li><a href="/tags/{{ $name | urlize }}/">{{ $name }} <span class="text-muted">({{ len $taxonomy }})</span></a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</article>
|
||||
{{ end }}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{{/* Ansicht nach Jahr — Kategorie hinter dem Beitrag, alle Beiträge */}}
|
||||
<div class="archiv-view" data-view="year" hidden>
|
||||
{{ $all := where site.RegularPages "Section" "archiv" }}
|
||||
{{ range $all.GroupByDate "2006" }}
|
||||
<article class="atlas-section">
|
||||
<h2>{{ .Key }}</h2>
|
||||
<ul class="atlas-list">
|
||||
{{ range .Pages }}
|
||||
<li>
|
||||
<a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
|
||||
{{ with .CurrentSection }}<span class="list-meta text-muted"> · {{ .LinkTitle }}</span>{{ end }}
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</article>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var bar = document.querySelector('.archiv-toggle'); if (!bar) return;
|
||||
var views = document.querySelectorAll('.archiv-view');
|
||||
function set(mode) {
|
||||
bar.querySelectorAll('button').forEach(function (b) { b.classList.toggle('is-active', b.dataset.mode === mode); });
|
||||
views.forEach(function (v) { v.hidden = v.dataset.view !== mode; });
|
||||
try { localStorage.setItem('ob_archiv_mode', mode); } catch (e) {}
|
||||
}
|
||||
bar.addEventListener('click', function (e) { var b = e.target.closest('button'); if (b) set(b.dataset.mode); });
|
||||
var saved; try { saved = localStorage.getItem('ob_archiv_mode'); } catch (e) {}
|
||||
if (saved === 'year') set('year');
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ else if eq .Path "/archiv/software" }}
|
||||
{{/* Software: kuratierte Landing — Werkzeuge (mit externem Link) getrennt
|
||||
von Texten & Anleitungen. */}}
|
||||
<header class="section-header" data-section="software">
|
||||
<p class="section-rubric">Archiv</p>
|
||||
<h1 class="section-title">{{ .Title }}</h1>
|
||||
{{ with .Params.description }}<p class="section-description">{{ . }}</p>{{ end }}
|
||||
</header>
|
||||
|
||||
{{ .Content }}
|
||||
|
||||
{{ $tools := where .RegularPages "Params.external" "!=" nil }}
|
||||
{{ $texts := where .RegularPages "Params.external" nil }}
|
||||
|
||||
{{ with $tools }}
|
||||
<section class="software-tools">
|
||||
<h2 class="software-h">Werkzeuge</h2>
|
||||
<ul class="tool-list">
|
||||
{{ range .ByWeight }}
|
||||
<li class="tool-item"{{ with .Params.color }} data-color="{{ . }}"{{ end }}>
|
||||
<a class="tool-main" href="{{ .RelPermalink }}">
|
||||
<span class="tool-name">{{ .LinkTitle }}</span>
|
||||
{{ with .Params.summary }}<span class="tool-sum text-muted">{{ . }}</span>{{ end }}
|
||||
</a>
|
||||
{{ with .Params.external }}<a class="tool-ext" href="{{ . }}" rel="noopener" aria-label="extern öffnen">↗</a>{{ end }}
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
<section class="software-texts">
|
||||
<h2 class="software-h">Texte & Anleitungen</h2>
|
||||
<div class="time-list" data-section="software">
|
||||
<ul>
|
||||
{{ range $texts.ByDate.Reverse }}
|
||||
<li class="list-item">
|
||||
<div class="list-title-row">
|
||||
<div class="list-title">
|
||||
<a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
|
||||
{{ with .Params.summary }}<div class="list-summary text-muted">{{ . }}</div>{{ end }}
|
||||
</div>
|
||||
<div class="list-meta">{{ partial "date.html" .Date }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{ else }}
|
||||
{{/* Archiv-Unterseite (Rubrik): gleiche Optik wie Übersichten */}}
|
||||
<div class="collection" style="--section-color: var(--palette-kusa)">
|
||||
<p class="section-rubric">Archiv</p>
|
||||
<h1 class="collection-title">{{ .Title }}</h1>
|
||||
<div class="collection-inner">
|
||||
{{ with .Params.description }}<p class="section-description">{{ . }}</p>{{ end }}
|
||||
{{ .Content }}
|
||||
<div class="time-list">
|
||||
<ul>
|
||||
{{ range .RegularPages.ByDate.Reverse }}
|
||||
<li class="list-item">
|
||||
<div class="list-title-row">
|
||||
<div class="list-title">
|
||||
<a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
|
||||
{{ with .Params.summary }}
|
||||
<div class="list-summary text-muted">{{ . }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="list-meta">{{ partial "date.html" .Date }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{{ define "main" }}
|
||||
{{ .Content }}
|
||||
|
||||
{{ $library := where site.RegularPages "Section" "library" }}
|
||||
{{ $journal := first 20 $library.ByDate.Reverse }}
|
||||
{{ $archiv := where site.RegularPages "Section" "archiv" }}
|
||||
{{ $journal := first 20 $archiv.ByDate.Reverse }}
|
||||
|
||||
<section class="journal" aria-label="Journal — neueste Beiträge">
|
||||
<header class="journal-header">
|
||||
@@ -21,8 +21,8 @@
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{{ if gt (len $library) 20 }}
|
||||
<p class="more"><a href="/library/">→ Alle Beiträge in der Library</a></p>
|
||||
{{ if gt (len $archiv) 20 }}
|
||||
<p class="more"><a href="/archiv/">→ Alle Beiträge im Archiv</a></p>
|
||||
{{ end }}
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
+83
-60
@@ -1,66 +1,89 @@
|
||||
{{ define "main" }}
|
||||
{{ .Content }}
|
||||
<div class="collection" style="--section-color: var(--palette-ichigo)">
|
||||
<h1 class="collection-title">{{ .Title }}</h1>
|
||||
<div class="collection-inner">
|
||||
{{ .Content }}
|
||||
|
||||
{{ if .IsSection }}
|
||||
{{ if eq .Path "/library" }}
|
||||
{{/* Library root: Atlas — gruppiert nach Untersection */}}
|
||||
<section class="atlas">
|
||||
{{ range .Sections.ByWeight }}
|
||||
{{ $section := path.Base .RelPermalink }}
|
||||
<article class="atlas-section" data-section="{{ $section }}">
|
||||
<h2><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
|
||||
{{ with .Params.description }}<p class="text-muted">{{ . }}</p>{{ end }}
|
||||
<ul class="atlas-list">
|
||||
{{ range first 6 .RegularPages.ByDate.Reverse }}
|
||||
<li>
|
||||
<a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
|
||||
<span class="list-meta text-muted"> · {{ partial "date.html" .Date }}</span>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ if gt (len .RegularPages) 6 }}
|
||||
<p class="more"><a href="{{ .RelPermalink }}">alle in {{ .Title }} →</a></p>
|
||||
{{ end }}
|
||||
</article>
|
||||
{{ end }}
|
||||
{{ $pages := where site.RegularPages "Section" "library" }}
|
||||
|
||||
{{/* Tag-Cloud */}}
|
||||
{{ with site.Taxonomies.tags }}
|
||||
<article class="atlas-tags">
|
||||
<h2>Tags</h2>
|
||||
<ul class="tag-cloud">
|
||||
{{ range $name, $taxonomy := . }}
|
||||
<li><a href="/tags/{{ $name | urlize }}/">{{ $name }} <span class="text-muted">({{ len $taxonomy }})</span></a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</article>
|
||||
{{ if $pages }}
|
||||
{{/* Gruppen sammeln + sortieren */}}
|
||||
{{ $groups := dict }}
|
||||
{{ range $pages }}
|
||||
{{ $g := .Params.group | default "Allgemein" }}
|
||||
{{ $existing := index $groups $g | default slice }}
|
||||
{{ $groups = merge $groups (dict $g ($existing | append .)) }}
|
||||
{{ end }}
|
||||
</section>
|
||||
{{ else }}
|
||||
{{/* Library subsection: chronologisch */}}
|
||||
{{ $section := path.Base .RelPermalink }}
|
||||
<header class="section-header" data-section="{{ $section }}">
|
||||
<p class="section-rubric">Library</p>
|
||||
<h1 class="section-title">{{ .Title }}</h1>
|
||||
{{ with .Params.description }}<p class="section-description">{{ . }}</p>{{ end }}
|
||||
</header>
|
||||
<div class="time-list" data-section="{{ $section }}">
|
||||
<ul>
|
||||
{{ range .RegularPages.ByDate.Reverse }}
|
||||
<li class="list-item">
|
||||
<div class="list-title-row">
|
||||
<div class="list-title">
|
||||
<a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
|
||||
{{ with .Params.summary }}
|
||||
<div class="list-summary text-muted">{{ . }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="list-meta">{{ partial "date.html" .Date }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ $groupNames := slice }}
|
||||
{{ range $g, $_ := $groups }}{{ $groupNames = $groupNames | append $g }}{{ end }}
|
||||
{{ $groupNames = sort $groupNames }}
|
||||
|
||||
<div class="lib-filter">
|
||||
<input id="lib-search" class="lib-search" type="search" placeholder="Suchen …" autocomplete="off" spellcheck="false">
|
||||
<div class="lib-pills">
|
||||
<button class="lib-pill active" data-group="">Alle</button>
|
||||
{{ range $groupNames }}<button class="lib-pill" data-group="{{ . }}">{{ . }}</button>{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="atlas atlas--grid2">
|
||||
{{ range $groupNames }}
|
||||
{{ $ps := index $groups . }}
|
||||
<article class="atlas-section" data-group="{{ . }}">
|
||||
<h2>{{ . }}</h2>
|
||||
<ul class="atlas-list">
|
||||
{{ range sort $ps "Title" }}
|
||||
{{ $norm := lower .Title }}
|
||||
{{ $norm = replace $norm "ä" "a" }}
|
||||
{{ $norm = replace $norm "ö" "o" }}
|
||||
{{ $norm = replace $norm "ü" "u" }}
|
||||
{{ $norm = replace $norm "ß" "ss" }}
|
||||
<li data-title="{{ $norm }}">
|
||||
<a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
|
||||
{{ with .Params.summary }}<span class="list-meta text-muted"> — {{ . }}</span>{{ end }}
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</article>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var input = document.getElementById('lib-search');
|
||||
var pills = document.querySelectorAll('.lib-pill');
|
||||
var activeGroup = '';
|
||||
|
||||
function filter() {
|
||||
var q = input.value.trim().toLowerCase()
|
||||
.replace(/ä/g,'a').replace(/ö/g,'o').replace(/ü/g,'u').replace(/ß/g,'ss');
|
||||
document.querySelectorAll('.atlas-section').forEach(function(sec) {
|
||||
var groupMatch = !activeGroup || sec.dataset.group === activeGroup;
|
||||
if (!groupMatch) { sec.style.display = 'none'; return; }
|
||||
var visible = 0;
|
||||
sec.querySelectorAll('li[data-title]').forEach(function(li) {
|
||||
var matchQ = !q || li.dataset.title.indexOf(q) !== -1;
|
||||
li.style.display = matchQ ? '' : 'none';
|
||||
if (matchQ) visible++;
|
||||
});
|
||||
sec.style.display = visible ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
input.addEventListener('input', filter);
|
||||
pills.forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
pills.forEach(function(b){ b.classList.remove('active'); });
|
||||
this.classList.add('active');
|
||||
activeGroup = this.dataset.group || '';
|
||||
filter();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{ else }}
|
||||
<p class="text-muted"><em>Noch keine Einträge — der erste entsteht im Redaktions-Editor.</em></p>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
{{ define "main" }}
|
||||
<article class="single library-entry" style="--section-color: var(--palette-ichigo)">
|
||||
<header class="single-header">
|
||||
<h1>{{ .Title }}</h1>
|
||||
{{ with .Params.summary }}<p class="single-summary">{{ . }}</p>{{ end }}
|
||||
</header>
|
||||
|
||||
{{ $hasToC := .Params.toc | default false }}
|
||||
{{ $headers := findRE "<h[2-6]" .Content }}
|
||||
{{ if and $hasToC (ge (len $headers) 2) }}
|
||||
<nav class="toc">
|
||||
<strong>Inhalt</strong>
|
||||
<div class="toc-content">{{ .TableOfContents }}</div>
|
||||
</nav>
|
||||
{{ end }}
|
||||
|
||||
{{/* Wiki-Links [[Titel]] / [[slug]] → Link auf die passende Library-Seite. */}}
|
||||
{{ $html := .Content }}
|
||||
{{ range (where site.RegularPages "Section" "library") }}
|
||||
{{ $a := printf `<a href="%s" class="wikilink">%s</a>` .RelPermalink .LinkTitle }}
|
||||
{{ $html = replace $html (printf "[[%s]]" .LinkTitle) $a }}
|
||||
{{ $html = replace $html (printf "[[%s]]" .File.ContentBaseName) $a }}
|
||||
{{ end }}
|
||||
{{/* Übrige (noch nicht angelegte) Verweise: ohne Klammern, dezent markiert. */}}
|
||||
{{ $html = replaceRE `\[\[([^\]]+)\]\]` `<span class="wikilink-missing" title="Seite existiert noch nicht">$1</span>` $html }}
|
||||
<div class="single-content">{{ $html | safeHTML }}</div>
|
||||
|
||||
{{/* ── Siehe auch: gleiche Gruppe + geteilte Tags ── */}}
|
||||
{{ $cur := . }}
|
||||
{{ $related := slice }}
|
||||
{{ range where (where site.RegularPages "Section" "library") "Params.group" .Params.group }}
|
||||
{{ if ne .RelPermalink $cur.RelPermalink }}{{ $related = $related | append . }}{{ end }}
|
||||
{{ end }}
|
||||
{{ with .Params.tags }}
|
||||
{{ range $t := . }}
|
||||
{{ range (where site.RegularPages "Section" "library") }}
|
||||
{{ if and (ne .RelPermalink $cur.RelPermalink) (in (.Params.tags | default slice) $t) }}
|
||||
{{ $related = $related | append . }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ $related = $related | uniq }}
|
||||
{{ with $related }}
|
||||
<nav class="entry-links" aria-label="Siehe auch">
|
||||
<span class="entry-links-label">Siehe auch</span>
|
||||
<ul>{{ range . }}<li><a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a></li>{{ end }}</ul>
|
||||
</nav>
|
||||
{{ end }}
|
||||
|
||||
{{/* ── Erwähnt in (Backlinks): Seiten, die per Link oder [[…]] hierher zeigen ── */}}
|
||||
{{ $back := slice }}
|
||||
{{ $url := .RelPermalink }}
|
||||
{{ $tok1 := printf "[[%s]]" .Title }}
|
||||
{{ $tok2 := printf "[[%s]]" .File.ContentBaseName }}
|
||||
{{ range site.RegularPages }}
|
||||
{{ if ne .RelPermalink $url }}
|
||||
{{ $raw := .RawContent }}
|
||||
{{ if or (in $raw $url) (in $raw $tok1) (in $raw $tok2) }}
|
||||
{{ $back = $back | append . }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ with $back }}
|
||||
<nav class="entry-links" aria-label="Erwähnt in">
|
||||
<span class="entry-links-label">Erwähnt in</span>
|
||||
<ul>{{ range . }}<li><a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a></li>{{ end }}</ul>
|
||||
</nav>
|
||||
{{ end }}
|
||||
|
||||
{{/* Fuss: Gruppe + zuletzt bearbeitet + bearbeiten. */}}
|
||||
<div class="entry-foot">
|
||||
<span class="entry-more"><strong>{{ .Params.group | default "Allgemein" }}</strong></span>
|
||||
{{ if .Lastmod }}<span>Zuletzt bearbeitet am {{ .Lastmod.Format "02.01.2006" }}</span>{{ end }}
|
||||
{{ with .File }}<a href="{{ site.Params.repoURL }}/_edit/branch/main/content/{{ .Path }}" rel="nofollow">bearbeiten ↗</a>{{ end }}
|
||||
</div>
|
||||
</article>
|
||||
{{ end }}
|
||||
+54
-4
@@ -36,6 +36,45 @@
|
||||
const api = (p, opt) => fetch(p, opt).then(async (r) => ({ ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }));
|
||||
const authHdr = () => ({ Authorization: 'Bearer ' + token });
|
||||
|
||||
// ── UX-Helfer ─────────────────────────────────────────────────────────────
|
||||
// Deterministische, dezente Avatar-Farbe aus dem Namen (wenn kein Bild).
|
||||
function hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return Math.abs(h) % 360; }
|
||||
function paintAvatar(av, name, avatarUrl) {
|
||||
if (avatarUrl) { av.style.backgroundImage = 'url(' + avatarUrl + ')'; av.textContent = ''; return; }
|
||||
const hue = hashHue(name || '?');
|
||||
av.style.background = 'hsl(' + hue + ' 36% 82%)';
|
||||
av.style.color = 'hsl(' + hue + ' 30% 28%)';
|
||||
av.textContent = (name || '?').trim().slice(0, 1).toUpperCase();
|
||||
}
|
||||
// URLs im Text klickbar machen — sicher: nur Text- + Anchor-Knoten, kein innerHTML.
|
||||
function linkify(container, text) {
|
||||
const re = /(https?:\/\/[^\s<]+)/g; let last = 0, m;
|
||||
while ((m = re.exec(text))) {
|
||||
if (m.index > last) container.appendChild(document.createTextNode(text.slice(last, m.index)));
|
||||
const a = el('a', 'dialog-link', m[0].replace(/[.,;:)]+$/, ''));
|
||||
a.href = a.textContent; a.target = '_blank'; a.rel = 'noopener noreferrer';
|
||||
container.appendChild(a);
|
||||
last = m.index + a.textContent.length;
|
||||
}
|
||||
if (last < text.length) container.appendChild(document.createTextNode(text.slice(last)));
|
||||
}
|
||||
// Dezente Lade-Platzhalter (schimmernd).
|
||||
function skeleton(container, n) {
|
||||
container.innerHTML = '';
|
||||
const w = el('div', 'dialog-skel');
|
||||
for (let i = 0; i < (n || 3); i++) w.appendChild(el('div', 'dialog-skel-line'));
|
||||
container.appendChild(w);
|
||||
}
|
||||
// ⌘/Ctrl + Enter sendet ab.
|
||||
function sendOnCmdEnter(ta, fn) {
|
||||
ta.addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); fn(); } });
|
||||
}
|
||||
// Textarea wächst mit dem Inhalt mit (bis zu einer Grenze).
|
||||
function autoGrow(ta, max) {
|
||||
const fit = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, max || 320) + 'px'; };
|
||||
ta.addEventListener('input', fit); requestAnimationFrame(fit);
|
||||
}
|
||||
|
||||
// ── Auth ────────────────────────────────────────────────────────────────
|
||||
async function doLogin(email, password, after) {
|
||||
const r = await api('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) });
|
||||
@@ -84,7 +123,11 @@
|
||||
right.appendChild(el('h2', 'dialog-title', 'Foren'));
|
||||
grid.append(left, right); root.appendChild(grid);
|
||||
|
||||
const recentSkel = el('div'); left.appendChild(recentSkel); skeleton(recentSkel, 5);
|
||||
const forumSkel = el('div'); right.appendChild(forumSkel); skeleton(forumSkel, 4);
|
||||
|
||||
api('/api/recent?limit=15').then((r) => {
|
||||
recentSkel.remove();
|
||||
const rows = r.body || [];
|
||||
if (!rows.length) { left.appendChild(el('p', 'dialog-empty', 'Noch keine Wortmeldungen.')); return; }
|
||||
const list = el('div', 'dialog-recent-list');
|
||||
@@ -101,6 +144,7 @@
|
||||
});
|
||||
|
||||
api('/api/forums').then((r) => {
|
||||
forumSkel.remove();
|
||||
const rows = r.body || [];
|
||||
const list = el('div', 'dialog-forum-list');
|
||||
rows.forEach((f) => {
|
||||
@@ -121,7 +165,9 @@
|
||||
function renderForum(slug) {
|
||||
root.innerHTML = '';
|
||||
if (ctxEl) { ctxEl.innerHTML = ''; const c = el('nav', 'dialog-crumb'); const b = el('a', null, '← Dialoge'); b.href = '/dialog/'; c.appendChild(b); ctxEl.appendChild(c); }
|
||||
const loadHost = el('div'); skeleton(loadHost, 4); root.appendChild(loadHost);
|
||||
api('/api/forums/' + encodeURIComponent(slug)).then((r) => {
|
||||
loadHost.remove();
|
||||
if (!r.ok) { root.appendChild(el('p', 'dialog-empty', 'Forum nicht gefunden.')); return; }
|
||||
const { forum, threads } = r.body;
|
||||
const head = el('div', 'dialog-forum-head');
|
||||
@@ -176,6 +222,8 @@
|
||||
if (!r.ok) { alert(r.body.error || 'Konnte Thread nicht anlegen'); return; }
|
||||
location.href = '/dialog/?thread=' + encodeURIComponent(r.body.key);
|
||||
};
|
||||
autoGrow(ta); sendOnCmdEnter(ta, () => send.click());
|
||||
ti.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); ta.focus(); } });
|
||||
row.append(send, cancel); box.append(ti, ta, row); ti.focus();
|
||||
}
|
||||
paint();
|
||||
@@ -189,6 +237,7 @@
|
||||
const list = el('div', 'dialog-list');
|
||||
const composer = el('div', 'dialog-composer');
|
||||
root.append(title, modbar, list, composer);
|
||||
skeleton(list, 3);
|
||||
let replyTo = null, textarea = null, locked = false;
|
||||
|
||||
// Kontext: Rücklink + Titel.
|
||||
@@ -249,8 +298,7 @@
|
||||
const post = el('article', 'dialog-post');
|
||||
const head = el('header', 'dialog-post-head');
|
||||
const av = el('span', 'dialog-avatar');
|
||||
if (c.author_avatar) av.style.backgroundImage = 'url(' + c.author_avatar + ')';
|
||||
else av.textContent = (c.author_name || '?').slice(0, 1).toUpperCase();
|
||||
paintAvatar(av, c.author_name, c.author_avatar);
|
||||
const ident = el('div', 'dialog-ident');
|
||||
const nameline = el('div', 'dialog-nameline');
|
||||
nameline.appendChild(el('span', 'dialog-name', c.author_name || 'Unbekannt'));
|
||||
@@ -258,7 +306,7 @@
|
||||
if (c.parent_id && names[c.parent_id]) nameline.appendChild(el('span', 'dialog-replyto', '↳ ' + names[c.parent_id]));
|
||||
ident.append(nameline, el('time', 'dialog-time', fmtFull(c.created_at)));
|
||||
head.append(av, ident); post.appendChild(head);
|
||||
post.appendChild(el('div', 'dialog-body', c.body));
|
||||
const bodyEl = el('div', 'dialog-body'); linkify(bodyEl, c.body || ''); post.appendChild(bodyEl);
|
||||
if (token && !c.deleted) {
|
||||
const actions = el('div', 'dialog-actions');
|
||||
if (!locked) { const rep = el('button', null, 'Antworten'); rep.onclick = () => { replyTo = { id: c.id, name: c.author_name }; renderComposer(); if (textarea) textarea.focus(); }; actions.appendChild(rep); }
|
||||
@@ -285,10 +333,12 @@
|
||||
composer.appendChild(r);
|
||||
}
|
||||
textarea = el('textarea', 'dialog-textarea'); textarea.placeholder = locked ? 'Thread gesperrt — nur Moderation …' : 'Deine Wortmeldung …';
|
||||
autoGrow(textarea); sendOnCmdEnter(textarea, submit);
|
||||
const row = el('div', 'dialog-row');
|
||||
const send = el('button', 'dialog-send', 'Senden'); send.onclick = submit;
|
||||
const hint = el('span', 'dialog-hint', '⌘ + ↵');
|
||||
const out = el('button', 'dialog-logout', 'Abmelden' + (myName ? ' · ' + myName : '')); out.onclick = () => logout(() => { renderModbar(); renderComposer(); load(); });
|
||||
row.append(send, out); composer.append(textarea, row);
|
||||
row.append(send, hint, el('span', 'dialog-spacer'), out); composer.append(textarea, row);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
|
||||
Reference in New Issue
Block a user