Merge commit '685592362aa6f53e981f9b4801d9a2e8ca318605'
This commit is contained in:
+113
-197
@@ -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 (
|
||||
<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>
|
||||
@@ -127,15 +132,15 @@ function Dashboard({ email }) {
|
||||
) : (
|
||||
<>
|
||||
<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', 'biblio', '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>
|
||||
@@ -149,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>
|
||||
</>
|
||||
)}
|
||||
@@ -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 }) {
|
||||
<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>
|
||||
@@ -257,61 +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="biblio">Library-Seite</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">
|
||||
{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>
|
||||
{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>
|
||||
|
||||
@@ -416,69 +400,37 @@ function Overview({ onMsg, go }) {
|
||||
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}</span>
|
||||
<span className="stat-value">{value ?? 0}</span>
|
||||
<span className="stat-label">{label}</span>
|
||||
<span className="stat-hint">{hint || ' '}</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">
|
||||
<Card label="Beiträge" value={s.content.beitraege} hint={`${s.content.entwuerfe} Entwürfe`} to="content" />
|
||||
<Card label="Library-Seiten" value={s.content.library} to="content" />
|
||||
<Card label="Seiten" value={s.content.seiten} />
|
||||
<Card label="Autor:innen" value={s.users.total} hint={`${s.users.admin} Admin · ${s.users.editor} Red.`} to="users" />
|
||||
<Card label="Foren" value={s.dialog.forums} to="forums" />
|
||||
<Card label="Threads" value={s.dialog.threads} to="moderation" />
|
||||
<Card label="Wortmeldungen" value={s.dialog.comments} to="moderation" />
|
||||
{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>
|
||||
<button onClick={() => go('forums')}>Foren verwalten</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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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) ──────────────────────────────────────
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 */ }
|
||||
}} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user