Merge commit '685592362aa6f53e981f9b4801d9a2e8ca318605'
This commit is contained in:
+42
-14
@@ -12,7 +12,8 @@
|
|||||||
`api/src/plugin-manager.js`, `api/src/plugins/dialog/index.js` + their tests.
|
`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`
|
- **Run tests:** `cd api && npm install && node --test` → **44 green**. `node_modules`
|
||||||
is git-ignored; the fresh clone needs `npm install` once.
|
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
|
cutover, `5e06337` DDL, `a93d11a` migration runner). Plugin-manager singleton
|
||||||
(`api/src/plugins.js`) wired into `index.js`/`stats.js`/`publish.js`, hard-coded
|
(`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
|
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`
|
`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 +
|
wired into the cms service env for openbureau (stage 8). Tested: migrate.test.js +
|
||||||
tracking SQL proven on the live dev Postgres (throwaway DB).
|
tracking SQL proven on the live dev Postgres (throwaway DB).
|
||||||
6. admin: `/api/schema` + App.jsx renders from schema (must reproduce openbureau's
|
6. [DONE, commits f709b5d + f8f4131 + 59b9e20] schema-driven admin. `GET /api/schema`
|
||||||
editor 1:1 when fed `openbureau.config.js`).
|
+ `GET /api/system` + a System panel; AND the editor now renders sidebar groups +
|
||||||
7. **auth provider** (`config.auth` = `supabase` | `local`). Verify is already local
|
fields from the site's collections (admin/src/fields.jsx: per-type controls
|
||||||
(HS256/JWT_SECRET in auth.js). Add a `local` provider: file-based users (bcrypt) +
|
string/text/slug/number/date/bool/select/list/image/color/markdown; form↔frontmatter
|
||||||
self-signed JWTs of the SAME claim shape (sub/email/app_metadata.role/exp), and a
|
+ path building). One admin serves any site. Unknown frontmatter is PRESERVED on
|
||||||
`/login` route to replace GoTrue; admin login (`admin/src/supabase.js`) calls it
|
save (__raw) — no data loss. Verified working via kgva (stage 9). NOTE: openbureau
|
||||||
instead of `signInWithPassword`; users.js CRUD edits the user file. Goal: a
|
is NOT yet redeployed with the schema editor (its running image keeps the bespoke
|
||||||
dialog-less core runs Node+Hugo+nginx, **no Supabase/Postgres** (~80 MB). openbureau
|
one); when it is, its config field types (color→swatch, layout opts) are already
|
||||||
stays `supabase`. Independent of the cutover — `supabase` is the default no-op.
|
tuned. Visual review of the openbureau editor by Karim still recommended before
|
||||||
8. cut openbureau over to consume core (= core + `dialog` plugin + `supabase` auth +
|
redeploying it.
|
||||||
its config) — test login / list / edit / preview / publish / dialog — identical.
|
7. [DONE, commit 86f9f57] **auth provider** (`config.auth` = `supabase` | `local`).
|
||||||
9. onboard kgva (`CMS_CONFIG=…/kgva.config.js`, `auth: 'local'`, no plugins) as the
|
`api/src/auth-local.js` = file-based users (bcryptjs) + self-signed HS256 JWTs of
|
||||||
2nd consumer — DB-less.
|
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 (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
|
## How to run / test
|
||||||
openbureau CMS stack: `OPENBUREAU/cms/docker-compose.yml` (Node api + Hugo +
|
openbureau CMS stack: `OPENBUREAU/cms/docker-compose.yml` (Node api + Hugo +
|
||||||
|
|||||||
+113
-197
@@ -3,33 +3,10 @@ import ToastEditor from '@toast-ui/editor';
|
|||||||
import '@toast-ui/editor/dist/toastui-editor.css';
|
import '@toast-ui/editor/dist/toastui-editor.css';
|
||||||
import { supabase } from './supabase.js';
|
import { supabase } from './supabase.js';
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
|
import {
|
||||||
// OPENBUREAU-Palette (Hex aus assets/css/custom.css) — für Dropdown + Punkte.
|
Field, blankForm, formFromFrontmatter, frontmatterFromForm, targetPath,
|
||||||
const COLORS = [
|
isShort, isBody, hexOf, DEFAULT_PALETTE,
|
||||||
['', 'keine', 'transparent'],
|
} from './fields.jsx';
|
||||||
['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: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [session, setSession] = useState(null);
|
const [session, setSession] = useState(null);
|
||||||
@@ -69,6 +46,9 @@ function Login() {
|
|||||||
|
|
||||||
function Dashboard({ email }) {
|
function Dashboard({ email }) {
|
||||||
const [entries, setEntries] = useState([]);
|
const [entries, setEntries] = useState([]);
|
||||||
|
const [collections, setCollections] = useState(null);
|
||||||
|
const [plugins, setPlugins] = useState([]);
|
||||||
|
const [site, setSite] = useState('');
|
||||||
const [current, setCurrent] = useState(null);
|
const [current, setCurrent] = useState(null);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [view, setView] = useState('content');
|
const [view, setView] = useState('content');
|
||||||
@@ -79,30 +59,55 @@ function Dashboard({ email }) {
|
|||||||
try { setEntries(await api.list()); }
|
try { setEntries(await api.list()); }
|
||||||
catch (e) { setMsg({ type: 'err', text: e.message }); }
|
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]);
|
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) {
|
async function open(entry) {
|
||||||
try { setCurrent(fromRead(await api.read(entry.path))); }
|
const col = collectionOf(entry.kind);
|
||||||
catch (err) { setMsg({ type: 'err', text: err.message }); }
|
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 q = query.trim().toLowerCase();
|
||||||
const filtered = q ? entries.filter((e) => e.title.toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
|
const filtered = q ? entries.filter((e) => (e.title || '').toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
|
||||||
const groups = { beitrag: [], biblio: [], seite: [], rubrik: [] };
|
const fallbackKind = (cols.find((c) => c.fallback) || cols[0] || {}).kind;
|
||||||
for (const e of filtered) (groups[e.kind] || groups.seite).push(e);
|
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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<span className="logo">OPENBUREAU</span>
|
<span className="logo">{(site || 'cms').toUpperCase()}</span>
|
||||||
<span className="logo-sub">Redaktion</span>
|
<span className="logo-sub">Redaktion</span>
|
||||||
<nav className="nav">
|
<nav className="nav">
|
||||||
{me?.isAdmin && <button className={view === 'overview' ? 'active' : ''} onClick={() => setView('overview')}>Übersicht</button>}
|
{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 === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
|
||||||
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
|
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
|
||||||
{me?.canModerate && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
|
{me?.canModerate && hasDialog && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
|
||||||
{me?.isAdmin && <button className={view === 'forums' ? 'active' : ''} onClick={() => setView('forums')}>Foren</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 === 'users' ? 'active' : ''} onClick={() => setView('users')}>Autor:innen</button>}
|
||||||
{me?.isAdmin && <button className={view === 'system' ? 'active' : ''} onClick={() => setView('system')}>System</button>}
|
{me?.isAdmin && <button className={view === 'system' ? 'active' : ''} onClick={() => setView('system')}>System</button>}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -127,15 +132,15 @@ function Dashboard({ email }) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<aside>
|
<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>
|
<div className="search"><span>⌕</span><input placeholder="Suchen…" value={query} onChange={(e) => setQuery(e.target.value)} /></div>
|
||||||
{['beitrag', 'biblio', 'seite', 'rubrik'].map((kind) => groups[kind].length > 0 && (
|
{cols.map((c) => groups[c.kind]?.length > 0 && (
|
||||||
<div className="group" key={kind}>
|
<div className="group" key={c.kind}>
|
||||||
<div className="group-title">{KIND_LABEL[kind]} <span>{groups[kind].length}</span></div>
|
<div className="group-title">{c.label} <span>{groups[c.kind].length}</span></div>
|
||||||
<ul className="list">
|
<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)}>
|
<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">
|
||||||
<span className="t-title">{e.title}</span>
|
<span className="t-title">{e.title}</span>
|
||||||
<span className="t-meta">{[e.section, e.date].filter(Boolean).join(' · ')}</span>
|
<span className="t-meta">{[e.section, e.date].filter(Boolean).join(' · ')}</span>
|
||||||
@@ -149,9 +154,9 @@ function Dashboard({ email }) {
|
|||||||
</aside>
|
</aside>
|
||||||
<main>
|
<main>
|
||||||
{current
|
{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} />
|
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>
|
</main>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -162,7 +167,7 @@ function Dashboard({ email }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Editor({ initial, onSaved, onMsg }) {
|
function Editor({ initial, collections, onSaved, onMsg }) {
|
||||||
const [f, setF] = useState(initial);
|
const [f, setF] = useState(initial);
|
||||||
const [previewUrl, setPreviewUrl] = useState(null);
|
const [previewUrl, setPreviewUrl] = useState(null);
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
@@ -170,17 +175,23 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const editorRef = useRef(null);
|
const editorRef = useRef(null);
|
||||||
const dragging = useRef(false);
|
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 cols = collections || [];
|
||||||
const file = ev.target.files?.[0]; ev.target.value = '';
|
const collection = cols.find((c) => c.kind === f.kind) || cols[0] || { fields: [] };
|
||||||
if (!file) return;
|
const fields = collection.fields || [];
|
||||||
setBusy(true);
|
const titleField = fields.find((x) => x.name === 'title') || fields.find((x) => x.type === 'string' && x.required) || null;
|
||||||
try { const { url } = await api.upload(file); setF((p) => ({ ...p, cover_image: url })); }
|
const bodyField = fields.find(isBody);
|
||||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
const draftField = fields.find((x) => x.type === 'bool' && x.name === 'draft');
|
||||||
finally { setBusy(false); }
|
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.
|
// 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 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 = {}) {
|
async function save(overrides = {}) {
|
||||||
const data = { ...f, ...overrides };
|
const data = { ...f, ...overrides };
|
||||||
const path = currentPath(data);
|
const path = data.isNew ? targetPath(data, collection) : data.path;
|
||||||
if (!path) { onMsg({ type: 'err', text: 'Bitte einen Slug angeben.' }); return null; }
|
if (!path) { onMsg({ type: 'err', text: 'Bitte einen Slug (und ggf. Pflichtsegmente wie Rubrik) angeben.' }); return null; }
|
||||||
if (!data.title.trim()) { onMsg({ type: 'err', text: 'Titel fehlt.' }); return null; }
|
if (titleField && !((data[titleField.name] || '').trim())) { onMsg({ type: 'err', text: 'Titel fehlt.' }); return null; }
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.save(path, buildFrontmatter(data), data.body);
|
const fm = frontmatterFromForm(data, collection);
|
||||||
const loaded = fromRead(await api.read(path));
|
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);
|
onSaved(loaded); setF(loaded);
|
||||||
return path;
|
return path;
|
||||||
} catch (e) { onMsg({ type: 'err', text: e.message }); return null; }
|
} catch (e) { onMsg({ type: 'err', text: e.message }); return null; }
|
||||||
@@ -229,8 +237,8 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
async function publish() {
|
async function publish() {
|
||||||
if (!confirm('Live publizieren? Der Beitrag wird aus „Entwurf“ genommen.')) return;
|
if (!confirm('Live publizieren?')) return;
|
||||||
const path = await save({ draft: false }); // Publizieren = nicht mehr Entwurf
|
const path = await save(draftField ? { [draftField.name]: false } : {});
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try { const res = await api.publish(path); onMsg({ type: 'ok', text: `Live: ${res.url}` }); }
|
try { const res = await api.publish(path); onMsg({ type: 'ok', text: `Live: ${res.url}` }); }
|
||||||
@@ -244,7 +252,7 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
<div className="editor-head">
|
<div className="editor-head">
|
||||||
<div className="crumb">{f.isNew ? 'Neuer Eintrag' : f.path}</div>
|
<div className="crumb">{f.isNew ? 'Neuer Eintrag' : f.path}</div>
|
||||||
<span className="spacer" />
|
<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">
|
<button className="toggle" onClick={() => setShowPreview((v) => !v)} title="Vorschau ein/aus">
|
||||||
{showPreview ? 'Vorschau ⤫' : 'Vorschau ⤢'}
|
{showPreview ? 'Vorschau ⤫' : 'Vorschau ⤢'}
|
||||||
</button>
|
</button>
|
||||||
@@ -257,61 +265,37 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
{f.isNew && (
|
{f.isNew && (
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="sm">Typ
|
<label className="sm">Typ
|
||||||
<select value={f.type} onChange={set('type')}>
|
<select value={f.kind} onChange={(e) => changeKind(e.target.value)}>
|
||||||
<option value="beitrag">Beitrag</option>
|
{cols.map((c) => <option key={c.kind} value={c.kind}>{c.label}</option>)}
|
||||||
<option value="biblio">Library-Seite</option>
|
|
||||||
<option value="seite">Seite</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{f.type === 'beitrag' && (
|
{!hasSlugField && (
|
||||||
<label className="sm">Rubrik
|
<label className="sm">Dateiname
|
||||||
<select value={f.section} onChange={set('section')}>{SECTIONS.map((s) => <option key={s}>{s}</option>)}</select>
|
<input value={f.slug || ''} onChange={(e) => setField('slug', e.target.value)} placeholder="z. B. impressum" />
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<label>Slug<input value={f.slug} onChange={set('slug')} placeholder="z.B. neuer-beitrag" /></label>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="big">Titel<input value={f.title} onChange={set('title')} placeholder="Titel des Beitrags" /></label>
|
{titleField && (
|
||||||
|
<label className="big">{titleField.label || 'Titel'}
|
||||||
<div className="meta">
|
<input value={f[titleField.name] || ''} onChange={(e) => setField(titleField.name, e.target.value)} placeholder="Titel" />
|
||||||
{isWiki && <label className="sm">Gruppe<input value={f.group} onChange={set('group')} placeholder="z. B. Begriffe" /></label>}
|
|
||||||
<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>
|
|
||||||
</label>
|
</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>
|
{shortFields.length > 0 && (
|
||||||
<div className="row">
|
<div className="meta">
|
||||||
<label>Cover-Bild
|
{shortFields.map((fld) => <Field key={fld.name} field={fld} value={f[fld.name]} onChange={setField} onUpload={upload} busy={busy} />)}
|
||||||
<div className="cover-row">
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="rich">
|
{longFields.map((fld) => <Field key={fld.name} field={fld} value={f[fld.name]} onChange={setField} onUpload={upload} busy={busy} />)}
|
||||||
<RichEditor value={f.body} onChange={(body) => setF((p) => ({ ...p, body }))}
|
|
||||||
onUpload={async (file) => (await api.upload(file)).url} />
|
{bodyField && (
|
||||||
</div>
|
<div className="rich">
|
||||||
|
<RichEditor value={f[bodyField.name] || ''} onChange={(body) => setField(bodyField.name, body)} onUpload={upload} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -416,69 +400,37 @@ function Overview({ onMsg, go }) {
|
|||||||
if (!s) return <div className="empty">…</div>;
|
if (!s) return <div className="empty">…</div>;
|
||||||
const Card = ({ label, value, hint, to }) => (
|
const Card = ({ label, value, hint, to }) => (
|
||||||
<button className="stat-card" onClick={to ? () => go(to) : undefined} disabled={!to}>
|
<button className="stat-card" onClick={to ? () => go(to) : undefined} disabled={!to}>
|
||||||
<span className="stat-value">{value}</span>
|
<span className="stat-value">{value ?? 0}</span>
|
||||||
<span className="stat-label">{label}</span>
|
<span className="stat-label">{label}</span>
|
||||||
<span className="stat-hint">{hint || ' '}</span>
|
<span className="stat-hint">{hint || ' '}</span>
|
||||||
</button>
|
</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 (
|
return (
|
||||||
<div className="overview">
|
<div className="overview">
|
||||||
<h2>Übersicht</h2>
|
<h2>Übersicht</h2>
|
||||||
<div className="stat-grid">
|
<div className="stat-grid">
|
||||||
<Card label="Beiträge" value={s.content.beitraege} hint={`${s.content.entwuerfe} Entwürfe`} to="content" />
|
{Object.entries(content).map(([k, v]) => <Card key={k} label={k} value={v} to="content" />)}
|
||||||
<Card label="Library-Seiten" value={s.content.library} to="content" />
|
<Card label="Autor:innen" value={s.users?.total} hint={`${s.users?.admin || 0} Admin · ${s.users?.editor || 0} Red.`} to="users" />
|
||||||
<Card label="Seiten" value={s.content.seiten} />
|
{hasDialog && <Card label="Foren" value={d.forums} to="forums" />}
|
||||||
<Card label="Autor:innen" value={s.users.total} hint={`${s.users.admin} Admin · ${s.users.editor} Red.`} to="users" />
|
{hasDialog && <Card label="Threads" value={d.threads} to="moderation" />}
|
||||||
<Card label="Foren" value={s.dialog.forums} to="forums" />
|
{hasDialog && <Card label="Wortmeldungen" value={d.comments} to="moderation" />}
|
||||||
<Card label="Threads" value={s.dialog.threads} to="moderation" />
|
|
||||||
<Card label="Wortmeldungen" value={s.dialog.comments} to="moderation" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="overview-actions">
|
<div className="overview-actions">
|
||||||
<h3>Schnellzugriff</h3>
|
<h3>Schnellzugriff</h3>
|
||||||
<div className="quick">
|
<div className="quick">
|
||||||
<button onClick={() => go('content')}>Inhalte bearbeiten</button>
|
<button onClick={() => go('content')}>Inhalte bearbeiten</button>
|
||||||
<button onClick={() => go('forums')}>Foren verwalten</button>
|
{hasDialog && <button onClick={() => go('forums')}>Foren verwalten</button>}
|
||||||
<button onClick={() => go('users')}>Autor:innen & Rollen</button>
|
<button onClick={() => go('users')}>Autor:innen & Rollen</button>
|
||||||
<a className="quick-link" href="/" target="_blank" rel="noreferrer">Website ↗</a>
|
<a className="quick-link" href="/" target="_blank" rel="noreferrer">Website ↗</a>
|
||||||
<a className="quick-link" href="/dialog/" target="_blank" rel="noreferrer">Dialog ↗</a>
|
|
||||||
<a className="quick-link" href="/library/" target="_blank" rel="noreferrer">Library ↗</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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 <div className="empty">…</div>;
|
|
||||||
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></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>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. Plugins & Content-Modell kommen aus der Site-Config.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
|
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
|
||||||
function Users({ onMsg, currentEmail }) {
|
function Users({ onMsg, currentEmail }) {
|
||||||
const [list, setList] = useState(null);
|
const [list, setList] = useState(null);
|
||||||
@@ -710,39 +662,3 @@ function slugify(s) {
|
|||||||
return (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
return (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||||
.replace(/[^a-z0-9]+/g, '-').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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 */ }
|
||||||
|
}} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,8 +19,8 @@ export default {
|
|||||||
{ name: 'slug', type: 'slug' },
|
{ name: 'slug', type: 'slug' },
|
||||||
{ name: 'date', type: 'date' },
|
{ name: 'date', type: 'date' },
|
||||||
{ name: 'weight', type: 'number' },
|
{ name: 'weight', type: 'number' },
|
||||||
{ name: 'color', type: 'string' },
|
{ name: 'color', type: 'color' },
|
||||||
{ name: 'layout', type: 'select', options: ['text'], default: 'text' },
|
{ name: 'layout', type: 'select', options: ['text', 'image', 'icon'], default: 'text' },
|
||||||
{ name: 'tags', type: 'list' },
|
{ name: 'tags', type: 'list' },
|
||||||
{ name: 'summary', type: 'text' },
|
{ name: 'summary', type: 'text' },
|
||||||
{ name: 'cover_image', type: 'image' },
|
{ name: 'cover_image', type: 'image' },
|
||||||
@@ -53,7 +53,7 @@ export default {
|
|||||||
statKey: 'rubriken',
|
statKey: 'rubriken',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'title', type: 'string', required: true },
|
{ name: 'title', type: 'string', required: true },
|
||||||
{ name: 'color', type: 'string' },
|
{ name: 'color', type: 'color' },
|
||||||
{ name: 'layout', type: 'string' },
|
{ name: 'layout', type: 'string' },
|
||||||
{ name: 'weight', type: 'number' },
|
{ name: 'weight', type: 'number' },
|
||||||
{ name: 'body', type: 'markdown' },
|
{ name: 'body', type: 'markdown' },
|
||||||
|
|||||||
Reference in New Issue
Block a user