From 685592362aa6f53e981f9b4801d9a2e8ca318605 Mon Sep 17 00:00:00 2001 From: Karim Gabriele Varano Date: Tue, 30 Jun 2026 18:36:26 +0200 Subject: [PATCH] Squashed 'cms/core/' changes from ac7538f..8f4068d MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8f4068d docs: HANDOVER — stage 6 (schema editor) + 9 (kgva on core) done; live cutovers pending 59b9e20 core: schema editor preserves unknown frontmatter (no data loss on save) f8f4131 core: stage 6 finish — schema-driven editor (admin renders from /api/schema) dd45602 docs: HANDOVER — stages 6 (partial)/7/8 done, openbureau on core; next = kgva git-subtree-dir: cms/core git-subtree-split: 8f4068d109d70d33b6619e039aff56ea3962802f --- HANDOVER.md | 56 ++++-- admin/src/App.jsx | 310 +++++++++++++--------------------- admin/src/fields.jsx | 154 +++++++++++++++++ examples/openbureau.config.js | 6 +- 4 files changed, 312 insertions(+), 214 deletions(-) create mode 100644 admin/src/fields.jsx diff --git a/HANDOVER.md b/HANDOVER.md index d54283b..afd5a9f 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -12,7 +12,8 @@ `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. -- **Immediate next action:** stage 5 + 5.5 **DONE** on the branch (commits `c4230a4` +- **Status:** stages 1–9 DONE (6 = full schema-driven editor; 9 = kgva on core, DB-less, staged-verified, repo pushed). openbureau runs on core (dev CT 134); kgva LIVE site still static (unchanged) with the CMS dormant in-repo. **Remaining: deliberate cutovers** — (a) redeploy openbureau with the schema editor after Karim eyeballs it; (b) provision the kgva-core CMS container + Caddy switch (his call, repoints a live domain). 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 @@ -125,19 +126,46 @@ openbureau's extras become plugins (first: `dialog`); kgva enables none. `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. admin: `/api/schema` + App.jsx renders from schema (must reproduce openbureau's - editor 1:1 when fed `openbureau.config.js`). -7. **auth provider** (`config.auth` = `supabase` | `local`). Verify is already local - (HS256/JWT_SECRET in auth.js). Add a `local` provider: file-based users (bcrypt) + - self-signed JWTs of the SAME claim shape (sub/email/app_metadata.role/exp), and a - `/login` route to replace GoTrue; admin login (`admin/src/supabase.js`) calls it - instead of `signInWithPassword`; users.js CRUD edits the user file. Goal: a - dialog-less core runs Node+Hugo+nginx, **no Supabase/Postgres** (~80 MB). openbureau - stays `supabase`. Independent of the cutover — `supabase` is the default no-op. -8. cut openbureau over to consume core (= core + `dialog` plugin + `supabase` auth + - its config) — test login / list / edit / preview / publish / dialog — identical. -9. onboard kgva (`CMS_CONFIG=…/kgva.config.js`, `auth: 'local'`, no plugins) as the - 2nd consumer — DB-less. +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 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 (repo + staged verify); live cutover PENDING] 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 + diff --git a/admin/src/App.jsx b/admin/src/App.jsx index 53faa9e..3f86b72 100644 --- a/admin/src/App.jsx +++ b/admin/src/App.jsx @@ -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', biblio: 'Library', 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: '', group: '', 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,55 @@ 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: [], biblio: [], 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 (
- OPENBUREAU + {(site || 'cms').toUpperCase()} Redaktion @@ -127,15 +132,15 @@ function Dashboard({ email }) { ) : ( <>
{current - ? { setCurrent(loaded); refresh(); }} onMsg={setMsg} /> - :

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

} + :

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

}
)} @@ -162,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); @@ -170,17 +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 }); - const isWiki = f.type === 'biblio' || (f.path || '').startsWith('library/'); - 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. @@ -197,25 +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 ''; - if (data.type === 'beitrag') return `archiv/${data.section}/${slug}.md`; - if (data.type === 'biblio') return `library/${slug}.md`; - return `${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; } @@ -229,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}` }); } @@ -244,7 +252,7 @@ function Editor({ initial, onSaved, onMsg }) {
{f.isNew ? 'Neuer Eintrag' : f.path}
- {f.draft ? Entwurf : Veröffentlicht} + {draftField && (isDraft ? Entwurf : Veröffentlicht)} @@ -257,61 +265,37 @@ function Editor({ initial, onSaved, onMsg }) { {f.isNew && (
- {f.type === 'beitrag' && ( -
)} - - -
- {isWiki && } - - -
+ )} - -
- - -
- + {shortFields.length > 0 && ( +
+ {shortFields.map((fld) => )} +
+ )} -
- setF((p) => ({ ...p, body }))} - onUpload={async (file) => (await api.upload(file)).url} /> -
+ {longFields.map((fld) => )} + + {bodyField && ( +
+ setField(bodyField.name, body)} onUpload={upload} /> +
+ )}
@@ -416,69 +400,37 @@ function Overview({ onMsg, go }) { if (!s) return
; const Card = ({ label, value, hint, to }) => ( ); + const content = s.content || {}; + const d = s.dialog || { forums: 0, threads: 0, comments: 0 }; + const hasDialog = (d.forums + d.threads + d.comments) > 0; return (

Übersicht

- - - - - - - + {Object.entries(content).map(([k, v]) => )} + + {hasDialog && } + {hasDialog && } + {hasDialog && }

Schnellzugriff

- + {hasDialog && } Website ↗ - Dialog ↗ - Library ↗
); } -// ── System (nur Admin): core-Version, Plugins, Content-Modell ──────────────── -function System({ onMsg }) { - const [s, setS] = useState(null); - useEffect(() => { api.system().then(setS).catch((e) => onMsg({ type: 'err', text: e.message })); }, []); - if (!s) return
; - return ( -
-
-

System

-
-
Core{s.core.name} v{s.core.version}
-
Site{s.site || '—'}
-
Auth{s.auth}
-
Hugo{s.hugo}
-
-

Plugins {s.plugins.length}

- {s.plugins.length - ?
    {s.plugins.map((p) => ( -
  • {p.name}{p.routes} Routen · {p.migrations} Migration(en)
  • - ))}
- :

Keine Plugins aktiv.

} -

Content-Modell {s.collections.length}

-
    {s.collections.map((c) => ( -
  • {c.label}{c.kind} · {c.fields} Felder{c.statKey ? ` · ${c.statKey}` : ''}
  • - ))}
-

openbureau-core fährt diese Site. Plugins & Content-Modell kommen aus der Site-Config.

-
-
- ); -} - // ── Autor:innen-Verwaltung (nur Admin) ────────────────────────────────────── function Users({ onMsg, currentEmail }) { const [list, setList] = useState(null); @@ -710,39 +662,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 || {}; - const p = r.path || ''; - const type = p.startsWith('archiv/') ? 'beitrag' : p.startsWith('library/') ? 'biblio' : 'seite'; - return { - isNew: false, path: r.path, type, 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 || ''), - group: fm.group || '', 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.group) fm.group = f.group; - if (f.toc) fm.toc = true; - if (f.draft) fm.draft = true; - return fm; -} diff --git a/admin/src/fields.jsx b/admin/src/fields.jsx new file mode 100644 index 0000000..63d41d2 --- /dev/null +++ b/admin/src/fields.jsx @@ -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 '.md' (or '.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 ; + } + if (field.type === 'select') { + return ( + + ); + } + if (field.type === 'color') { + return ( + + ); + } + if (field.type === 'image') { + return ( + + ); + } + if (field.type === 'text') { + return