733 lines
34 KiB
React
733 lines
34 KiB
React
import { useEffect, useRef, useState } from 'react';
|
||
import ToastEditor from '@toast-ui/editor';
|
||
import '@toast-ui/editor/dist/toastui-editor.css';
|
||
import { supabase } from './supabase.js';
|
||
import { api } from './api.js';
|
||
import {
|
||
Field, blankForm, formFromFrontmatter, frontmatterFromForm, targetPath,
|
||
isShort, isBody, hexOf, DEFAULT_PALETTE,
|
||
} from './fields.jsx';
|
||
|
||
export default function App() {
|
||
const [session, setSession] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
useEffect(() => {
|
||
supabase.auth.getSession().then(({ data }) => { setSession(data.session); setLoading(false); });
|
||
const { data: sub } = supabase.auth.onAuthStateChange((_e, s) => setSession(s));
|
||
return () => sub.subscription.unsubscribe();
|
||
}, []);
|
||
if (loading) return <div className="center muted">…</div>;
|
||
if (!session) return <Login />;
|
||
return <Dashboard email={session.user.email} />;
|
||
}
|
||
|
||
function Login() {
|
||
const [email, setEmail] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [err, setErr] = useState(null);
|
||
async function submit(e) {
|
||
e.preventDefault(); setErr(null);
|
||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||
if (error) setErr(error.message);
|
||
}
|
||
return (
|
||
<div className="center">
|
||
<form className="login" onSubmit={submit}>
|
||
<div className="login-brand">OPENBUREAU</div>
|
||
<div className="login-sub">Redaktion</div>
|
||
<input type="email" placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} autoFocus />
|
||
<input type="password" placeholder="Passwort" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||
<button type="submit">Anmelden</button>
|
||
{err && <p className="err">{err}</p>}
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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');
|
||
const [me, setMe] = useState(null);
|
||
const [msg, setMsg] = useState(null);
|
||
|
||
async function refresh() {
|
||
try { setEntries(await api.list()); }
|
||
catch (e) { setMsg({ type: 'err', text: e.message }); }
|
||
}
|
||
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) {
|
||
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 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">{(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 && hasDialog && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
|
||
{me?.isAdmin && hasDialog && <button className={view === 'forums' ? 'active' : ''} onClick={() => setView('forums')}>Foren</button>}
|
||
{me?.isAdmin && <button className={view === 'users' ? 'active' : ''} onClick={() => setView('users')}>Autor:innen</button>}
|
||
{me?.isAdmin && <button className={view === 'system' ? 'active' : ''} onClick={() => setView('system')}>System</button>}
|
||
</nav>
|
||
<span className="spacer" />
|
||
<span className="who">{email}</span>
|
||
<button className="ghost" onClick={() => supabase.auth.signOut()}>Abmelden</button>
|
||
</header>
|
||
|
||
<div className="body">
|
||
{view === 'overview' ? (
|
||
<Overview onMsg={setMsg} go={setView} />
|
||
) : view === 'profile' ? (
|
||
<Profile onMsg={setMsg} />
|
||
) : view === 'users' ? (
|
||
<Users onMsg={setMsg} currentEmail={me?.email} />
|
||
) : view === 'forums' ? (
|
||
<Forums onMsg={setMsg} />
|
||
) : view === 'moderation' ? (
|
||
<Moderation onMsg={setMsg} />
|
||
) : view === 'system' ? (
|
||
<System onMsg={setMsg} />
|
||
) : (
|
||
<>
|
||
<aside>
|
||
<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>
|
||
{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[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(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>
|
||
</span>
|
||
{e.draft && <span className="draft-tag">Entwurf</span>}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
))}
|
||
</aside>
|
||
<main>
|
||
{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 an.</p></div>}
|
||
</main>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{msg && <div className={`toast ${msg.type}`} onClick={() => setMsg(null)}>{msg.text}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Editor({ initial, collections, onSaved, onMsg }) {
|
||
const [f, setF] = useState(initial);
|
||
const [previewUrl, setPreviewUrl] = useState(null);
|
||
const [showPreview, setShowPreview] = useState(false);
|
||
const [pw, setPw] = useState(44);
|
||
const [busy, setBusy] = useState(false);
|
||
const editorRef = useRef(null);
|
||
const dragging = useRef(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.
|
||
useEffect(() => {
|
||
function move(e) {
|
||
if (!dragging.current || !editorRef.current) return;
|
||
const r = editorRef.current.getBoundingClientRect();
|
||
setPw(Math.min(70, Math.max(25, ((r.right - e.clientX) / r.width) * 100)));
|
||
}
|
||
function up() { dragging.current = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; }
|
||
window.addEventListener('mousemove', move);
|
||
window.addEventListener('mouseup', up);
|
||
return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
|
||
}, []);
|
||
function startDrag(e) { dragging.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }
|
||
|
||
async function save(overrides = {}) {
|
||
const data = { ...f, ...overrides };
|
||
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 {
|
||
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; }
|
||
finally { setBusy(false); }
|
||
}
|
||
async function preview() {
|
||
const path = await save(); if (!path) return;
|
||
setShowPreview(true); setBusy(true);
|
||
try { const res = await api.preview(path); setPreviewUrl(`${res.url}?t=${Date.now()}`); }
|
||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||
finally { setBusy(false); }
|
||
}
|
||
async function publish() {
|
||
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}` }); }
|
||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||
finally { setBusy(false); }
|
||
}
|
||
|
||
return (
|
||
<div className="editor" ref={editorRef}>
|
||
<div className="editor-main">
|
||
<div className="editor-head">
|
||
<div className="crumb">{f.isNew ? 'Neuer Eintrag' : f.path}</div>
|
||
<span className="spacer" />
|
||
{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>
|
||
<button onClick={() => save().then((p) => p && onMsg({ type: 'ok', text: 'Gespeichert.' }))} disabled={busy}>Speichern</button>
|
||
<button onClick={preview} disabled={busy}>Vorschau</button>
|
||
<button className="primary" onClick={publish} disabled={busy}>Publizieren</button>
|
||
</div>
|
||
|
||
<div className="fields">
|
||
{f.isNew && (
|
||
<div className="row">
|
||
<label className="sm">Typ
|
||
<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>
|
||
{!hasSlugField && (
|
||
<label className="sm">Dateiname
|
||
<input value={f.slug || ''} onChange={(e) => setField('slug', e.target.value)} placeholder="z. B. impressum" />
|
||
</label>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{titleField && (
|
||
<label className="big">{titleField.label || 'Titel'}
|
||
<input value={f[titleField.name] || ''} onChange={(e) => setField(titleField.name, e.target.value)} placeholder="Titel" />
|
||
</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>
|
||
)}
|
||
|
||
{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>
|
||
|
||
{showPreview && <div className="splitter" onMouseDown={startDrag} />}
|
||
{showPreview && (
|
||
<div className="preview" style={{ width: pw + '%' }}>
|
||
{previewUrl
|
||
? <iframe title="Vorschau" src={previewUrl} />
|
||
: <div className="empty small"><p>Auf „Vorschau“ klicken — die Seite erscheint hier in deinem echten Theme.</p></div>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── WYSIWYG-Editor (Toast UI, vanilla) — Formatierung live, speichert Markdown ──
|
||
function RichEditor({ value, onChange, onUpload }) {
|
||
const el = useRef(null);
|
||
const inst = useRef(null);
|
||
// value/onChange/onUpload in Refs, damit der Editor nur EINMAL erzeugt wird.
|
||
const cb = useRef({ onChange, onUpload });
|
||
cb.current = { onChange, onUpload };
|
||
|
||
useEffect(() => {
|
||
inst.current = new ToastEditor({
|
||
el: el.current,
|
||
initialValue: value || '',
|
||
initialEditType: 'wysiwyg',
|
||
previewStyle: 'tab',
|
||
height: '100%',
|
||
usageStatistics: false,
|
||
autofocus: false,
|
||
toolbarItems: [
|
||
['heading', 'bold', 'italic', 'strike'],
|
||
['hr', 'quote'],
|
||
['ul', 'ol'],
|
||
['link', 'image'],
|
||
['code', 'codeblock'],
|
||
],
|
||
hooks: {
|
||
addImageBlobHook: async (blob, done) => {
|
||
try { done(await cb.current.onUpload(blob), blob.name || 'bild'); }
|
||
catch { /* Upload fehlgeschlagen */ }
|
||
},
|
||
},
|
||
events: { change: () => cb.current.onChange(inst.current.getMarkdown()) },
|
||
});
|
||
return () => { inst.current?.destroy(); inst.current = null; };
|
||
}, []);
|
||
|
||
return <div ref={el} className="rich-host" />;
|
||
}
|
||
|
||
// ── Profil ──────────────────────────────────────────────────────────────────
|
||
function Profile({ onMsg }) {
|
||
const [p, setP] = useState(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const fileIn = useRef(null);
|
||
useEffect(() => { api.getProfile().then(setP).catch((e) => onMsg({ type: 'err', text: e.message })); }, []);
|
||
if (!p) return <div className="empty">…</div>;
|
||
const set = (k) => (e) => setP({ ...p, [k]: e.target.value });
|
||
|
||
async function pickAvatar(ev) {
|
||
const file = ev.target.files?.[0]; ev.target.value = '';
|
||
if (!file) return;
|
||
setBusy(true);
|
||
try { const { url } = await api.upload(file); setP((x) => ({ ...x, avatar: url })); }
|
||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||
finally { setBusy(false); }
|
||
}
|
||
async function save() {
|
||
setBusy(true);
|
||
try { await api.saveProfile({ name: p.name, bio: p.bio, avatar: p.avatar }); onMsg({ type: 'ok', text: 'Profil gespeichert.' }); }
|
||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||
finally { setBusy(false); }
|
||
}
|
||
|
||
return (
|
||
<div className="profile">
|
||
<div className="profile-card">
|
||
<h2>Profil</h2>
|
||
<div className="avatar-row">
|
||
<div className="avatar" style={{ backgroundImage: p.avatar ? `url(${p.avatar})` : 'none' }}>{!p.avatar && '🙂'}</div>
|
||
<div>
|
||
<button onClick={() => fileIn.current?.click()} disabled={busy}>Profilbild wählen</button>
|
||
<input ref={fileIn} type="file" accept="image/*" hidden onChange={pickAvatar} />
|
||
<p className="muted who-mail">{p.email}</p>
|
||
</div>
|
||
</div>
|
||
<label>Name<input value={p.name} onChange={set('name')} placeholder="Dein Name" /></label>
|
||
<label>Über mich<textarea value={p.bio} onChange={set('bio')} rows={5} placeholder="Kurzer Text über dich…" /></label>
|
||
<div className="actions"><button className="primary" onClick={save} disabled={busy}>Speichern</button></div>
|
||
</div>
|
||
<PasswordChange onMsg={onMsg} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PasswordChange({ onMsg }) {
|
||
const [cur, setCur] = useState('');
|
||
const [nxt, setNxt] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
async function submit(e) {
|
||
e.preventDefault();
|
||
if (nxt.length < 6) { onMsg({ type: 'err', text: 'Neues Passwort zu kurz (min. 6 Zeichen).' }); return; }
|
||
setBusy(true);
|
||
try { await api.changePassword(cur, nxt); onMsg({ type: 'ok', text: 'Passwort geändert.' }); setCur(''); setNxt(''); }
|
||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||
finally { setBusy(false); }
|
||
}
|
||
return (
|
||
<div className="profile-card">
|
||
<h2>Passwort ändern</h2>
|
||
<form onSubmit={submit}>
|
||
<label>Aktuelles Passwort<input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" /></label>
|
||
<label>Neues Passwort<input type="password" value={nxt} onChange={(e) => setNxt(e.target.value)} autoComplete="new-password" /></label>
|
||
<div className="actions"><button className="primary" disabled={busy}>Passwort setzen</button></div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Übersicht / Dashboard (nur Admin) ───────────────────────────────────────
|
||
function Overview({ onMsg, go }) {
|
||
const [s, setS] = useState(null);
|
||
useEffect(() => { api.stats().then(setS).catch((e) => onMsg({ type: 'err', text: e.message })); }, []);
|
||
if (!s) return <div className="empty">…</div>;
|
||
const Card = ({ label, value, hint, to }) => (
|
||
<button className="stat-card" onClick={to ? () => go(to) : undefined} disabled={!to}>
|
||
<span className="stat-value">{value ?? 0}</span>
|
||
<span className="stat-label">{label}</span>
|
||
<span className="stat-hint">{hint || ' '}</span>
|
||
</button>
|
||
);
|
||
const content = s.content || {};
|
||
const d = s.dialog || { forums: 0, threads: 0, comments: 0 };
|
||
const hasDialog = (d.forums + d.threads + d.comments) > 0;
|
||
return (
|
||
<div className="overview">
|
||
<h2>Übersicht</h2>
|
||
<div className="stat-grid">
|
||
{Object.entries(content).map(([k, v]) => <Card key={k} label={k} value={v} to="content" />)}
|
||
<Card label="Autor:innen" value={s.users?.total} hint={`${s.users?.admin || 0} Admin · ${s.users?.editor || 0} Red.`} to="users" />
|
||
{hasDialog && <Card label="Foren" value={d.forums} to="forums" />}
|
||
{hasDialog && <Card label="Threads" value={d.threads} to="moderation" />}
|
||
{hasDialog && <Card label="Wortmeldungen" value={d.comments} to="moderation" />}
|
||
</div>
|
||
<div className="overview-actions">
|
||
<h3>Schnellzugriff</h3>
|
||
<div className="quick">
|
||
<button onClick={() => go('content')}>Inhalte bearbeiten</button>
|
||
{hasDialog && <button onClick={() => go('forums')}>Foren verwalten</button>}
|
||
<button onClick={() => go('users')}>Autor:innen & Rollen</button>
|
||
<a className="quick-link" href="/" target="_blank" rel="noreferrer">Website ↗</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── System (nur Admin): core-Version, Update-Check, Admins, Plugins, Modell ──
|
||
function System({ onMsg }) {
|
||
const [s, setS] = useState(null);
|
||
const [upd, setUpd] = useState(null);
|
||
useEffect(() => {
|
||
api.system().then(setS).catch((e) => onMsg({ type: 'err', text: e.message }));
|
||
api.systemUpdate().then(setUpd).catch(() => {});
|
||
}, []);
|
||
if (!s) return <div className="empty">…</div>;
|
||
const badge = !upd ? null
|
||
: upd.available ? <span className="status draft">core v{upd.vendored} bereit — Rebuild nötig</span>
|
||
: upd.vendored ? <span className="status live">aktuell</span>
|
||
: <span className="muted">(kein Subtree)</span>;
|
||
return (
|
||
<div className="profile">
|
||
<div className="profile-card wide">
|
||
<h2>System</h2>
|
||
<div className="sysgrid">
|
||
<div><span className="muted">Core</span><b>{s.core.name} <span className="ver">v{s.core.version}</span></b> {badge}</div>
|
||
<div><span className="muted">Site</span><b>{s.site || '—'}</b></div>
|
||
<div><span className="muted">Auth</span><b>{s.auth}</b></div>
|
||
<div><span className="muted">Hugo</span><b>{s.hugo}</b></div>
|
||
</div>
|
||
<h3>Admins <span className="count-pill">{(s.admins || []).length}</span></h3>
|
||
<ul className="syslist">{(s.admins || []).map((a) => (
|
||
<li key={a}><b>{a}</b><span className="muted">aus Config (fix)</span></li>
|
||
))}</ul>
|
||
<h3>Plugins <span className="count-pill">{s.plugins.length}</span></h3>
|
||
{s.plugins.length
|
||
? <ul className="syslist">{s.plugins.map((p) => (
|
||
<li key={p.name}><b>{p.name}</b><span className="muted">{p.routes} Routen · {p.migrations} Migration(en)</span></li>
|
||
))}</ul>
|
||
: <p className="muted">Keine Plugins aktiv.</p>}
|
||
<h3>Content-Modell <span className="count-pill">{s.collections.length}</span></h3>
|
||
<ul className="syslist">{s.collections.map((c) => (
|
||
<li key={c.kind}><b>{c.label}</b><span className="muted">{c.kind} · {c.fields} Felder{c.statKey ? ` · ${c.statKey}` : ''}</span></li>
|
||
))}</ul>
|
||
<p className="muted who-mail">openbureau-core fährt diese Site. Updates werden serverseitig eingespielt (Update-Skript).</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
|
||
function Users({ onMsg, currentEmail }) {
|
||
const [list, setList] = useState(null);
|
||
const [email, setEmail] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [role, setRole] = useState('user');
|
||
const [busy, setBusy] = useState(false);
|
||
const [q, setQ] = useState('');
|
||
const [pwFor, setPwFor] = useState(null);
|
||
const [newPw, setNewPw] = useState('');
|
||
|
||
async function refresh() {
|
||
try { setList(await api.listUsers()); }
|
||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||
}
|
||
useEffect(() => { refresh(); }, []);
|
||
|
||
async function create(e) {
|
||
e.preventDefault(); setBusy(true);
|
||
try {
|
||
await api.createUser(email, password, role);
|
||
onMsg({ type: 'ok', text: 'Autor:in angelegt.' });
|
||
setEmail(''); setPassword(''); setRole('user'); refresh();
|
||
} catch (err) { onMsg({ type: 'err', text: err.message }); }
|
||
finally { setBusy(false); }
|
||
}
|
||
async function remove(u) {
|
||
if (!confirm(`${u.email} wirklich löschen?`)) return;
|
||
try { await api.deleteUser(u.id); refresh(); }
|
||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||
}
|
||
async function savePw(u) {
|
||
if (!newPw || newPw.length < 6) { onMsg({ type: 'err', text: 'Passwort zu kurz (min. 6 Zeichen).' }); return; }
|
||
try { await api.setPassword(u.id, newPw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); setPwFor(null); setNewPw(''); }
|
||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||
}
|
||
async function changeRole(u, r) {
|
||
try { await api.setRole(u.id, r); onMsg({ type: 'ok', text: `Rolle: ${ROLE_LABEL[r]}` }); refresh(); }
|
||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||
}
|
||
|
||
if (!list) return <div className="empty">…</div>;
|
||
const filtered = q ? list.filter((u) => u.email.toLowerCase().includes(q.toLowerCase())) : list;
|
||
const RoleSelect = ({ u }) => (
|
||
<select className="role-select" value={u.role} onChange={(e) => changeRole(u, e.target.value)}>
|
||
<option value="user">User</option><option value="editor">Redakteur</option><option value="admin">Admin</option>
|
||
</select>
|
||
);
|
||
return (
|
||
<div className="profile">
|
||
<div className="profile-card wide">
|
||
<h2>Autor:innen & Rollen <span className="count-pill">{list.length}</span></h2>
|
||
<form className="userform" onSubmit={create}>
|
||
<input type="email" placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||
<input type="text" placeholder="Passwort" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||
<select className="role-select" value={role} onChange={(e) => setRole(e.target.value)}>
|
||
<option value="user">User</option><option value="editor">Redakteur</option><option value="admin">Admin</option>
|
||
</select>
|
||
<button className="primary" disabled={busy}>Anlegen</button>
|
||
</form>
|
||
{list.length > 6 && <input className="userfilter" placeholder="filtern…" value={q} onChange={(e) => setQ(e.target.value)} />}
|
||
<ul className="userlist">
|
||
{filtered.map((u) => (
|
||
<li key={u.id}>
|
||
<span className="uavatar" style={avatarStyle(u.email)}>{(u.email || '?').slice(0, 1).toUpperCase()}</span>
|
||
<span className="t ucol">
|
||
<span className="uemail">{u.email}{u.email === currentEmail && <span className="you"> · du</span>}</span>
|
||
<span className="umeta">
|
||
angelegt {fmtDate(u.created_at)}
|
||
{u.last_sign_in_at ? ` · zuletzt aktiv ${fmtDate(u.last_sign_in_at)}` : ' · nie angemeldet'}
|
||
</span>
|
||
</span>
|
||
{u.fixedAdmin ? <span className="rolebadge admin">Admin · .env</span> : <RoleSelect u={u} />}
|
||
{pwFor === u.id ? (
|
||
<span className="pwinline">
|
||
<input type="text" placeholder="neues Passwort" value={newPw} autoFocus
|
||
onChange={(e) => setNewPw(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') savePw(u); if (e.key === 'Escape') { setPwFor(null); setNewPw(''); } }} />
|
||
<button onClick={() => savePw(u)}>OK</button>
|
||
<button onClick={() => { setPwFor(null); setNewPw(''); }}>✕</button>
|
||
</span>
|
||
) : (
|
||
<button onClick={() => { setPwFor(u.id); setNewPw(''); }}>Passwort</button>
|
||
)}
|
||
{u.email !== currentEmail && !u.fixedAdmin && <button onClick={() => remove(u)}>Löschen</button>}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<p className="muted who-mail"><b>User</b> schreiben im Forum · <b>Redakteur</b> moderiert · <b>Admin</b> verwaltet alles. Admins aus <code>ADMIN_EMAILS</code> sind fix.</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const ROLE_LABEL = { user: 'User', editor: 'Redakteur', admin: 'Admin' };
|
||
function fmtDate(ts) { if (!ts) return '—'; try { return new Date(ts).toLocaleDateString('de-CH'); } catch { return '—'; } }
|
||
function uHashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return Math.abs(h) % 360; }
|
||
function avatarStyle(s) { const h = uHashHue(s); return { background: `hsl(${h} 36% 82%)`, color: `hsl(${h} 30% 28%)` }; }
|
||
|
||
// ── Foren-Verwaltung (nur Admin) ────────────────────────────────────────────
|
||
function Forums({ onMsg }) {
|
||
const [list, setList] = useState(null);
|
||
const [draft, setDraft] = useState({ slug: '', name: '', sort: 50 });
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
async function refresh() {
|
||
try { setList(await api.listForumsAdmin()); }
|
||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||
}
|
||
useEffect(() => { refresh(); }, []);
|
||
|
||
async function create(e) {
|
||
e.preventDefault();
|
||
if (!draft.slug || !draft.name) return;
|
||
setBusy(true);
|
||
try { await api.createForum(draft); onMsg({ type: 'ok', text: 'Kategorie angelegt.' }); setDraft({ slug: '', name: '', sort: 50 }); refresh(); }
|
||
catch (err) { onMsg({ type: 'err', text: err.message }); }
|
||
finally { setBusy(false); }
|
||
}
|
||
async function save(f, patch) {
|
||
try { await api.updateForum(f.id, patch); refresh(); }
|
||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||
}
|
||
async function remove(f) {
|
||
if (!confirm(`Kategorie „${f.name}“ löschen? Threads darin verschwinden.`)) return;
|
||
try { await api.deleteForum(f.id); onMsg({ type: 'ok', text: 'Gelöscht.' }); refresh(); }
|
||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||
}
|
||
|
||
if (!list) return <div className="empty">…</div>;
|
||
return (
|
||
<div className="profile">
|
||
<div className="profile-card wide">
|
||
<h2>Foren / Kategorien</h2>
|
||
<form className="userform" onSubmit={create}>
|
||
<input placeholder="Name (z. B. Wettbewerbe)" value={draft.name}
|
||
onChange={(e) => setDraft({ ...draft, name: e.target.value, slug: draft.slug || slugify(e.target.value) })} required />
|
||
<input placeholder="slug" value={draft.slug} onChange={(e) => setDraft({ ...draft, slug: slugify(e.target.value) })} required />
|
||
<input type="number" placeholder="Sort" style={{ width: '5em' }} value={draft.sort} onChange={(e) => setDraft({ ...draft, sort: e.target.value })} />
|
||
<button className="primary" disabled={busy}>Anlegen</button>
|
||
</form>
|
||
<ul className="forumlist">
|
||
{list.map((f) => (
|
||
<li key={f.id} className={f.kind === 'library' ? 'is-library' : ''}>
|
||
<span className="fsort">{f.sort}</span>
|
||
<input className="fname" defaultValue={f.name} onBlur={(e) => e.target.value !== f.name && save(f, { name: e.target.value })} />
|
||
<input className="fcolor" type="color" value={/^#[0-9a-fA-F]{6}$/.test(f.color || '') ? f.color : '#cccccc'} onChange={(e) => save(f, { color: e.target.value })} title="Akzentfarbe" />
|
||
<span className="fslug">/{f.slug}</span>
|
||
{f.kind === 'library'
|
||
? <span className="status">Library (auto)</span>
|
||
: <button onClick={() => remove(f)}>Löschen</button>}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<p className="muted who-mail">„Beiträge“ ist die automatische Library-Kategorie und kann nicht gelöscht werden.</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Moderation (Admin + Redakteur) ──────────────────────────────────────────
|
||
function Moderation({ onMsg }) {
|
||
const [data, setData] = useState(null);
|
||
|
||
async function refresh() {
|
||
try { setData(await api.modOverview()); }
|
||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||
}
|
||
useEffect(() => { refresh(); }, []);
|
||
|
||
async function delComment(c) {
|
||
if (!confirm('Wortmeldung löschen?')) return;
|
||
try { await api.deleteComment(c.id); onMsg({ type: 'ok', text: 'Gelöscht.' }); refresh(); }
|
||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||
}
|
||
async function toggleLock(t) {
|
||
try { await api.lockThread(t.key, !t.locked); refresh(); }
|
||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||
}
|
||
async function delThread(t) {
|
||
if (!confirm(`Thread „${t.title}“ ausblenden?`)) return;
|
||
try { await api.deleteThread(t.key); onMsg({ type: 'ok', text: 'Ausgeblendet.' }); refresh(); }
|
||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||
}
|
||
|
||
if (!data) return <div className="empty">…</div>;
|
||
return (
|
||
<div className="moderation">
|
||
<div className="mod-col">
|
||
<h2>Letzte Wortmeldungen</h2>
|
||
<ul className="modlist">
|
||
{data.comments.map((c) => (
|
||
<li key={c.id}>
|
||
<div className="mod-head"><b>{c.author_name}</b>
|
||
<span className="muted"> · {c.forum_name || '—'} · {c.thread_title}</span></div>
|
||
<div className="mod-body">{c.body}</div>
|
||
<div className="mod-actions">
|
||
<a href={c.thread_url} target="_blank" rel="noreferrer">öffnen</a>
|
||
<button onClick={() => delComment(c)}>Löschen</button>
|
||
</div>
|
||
</li>
|
||
))}
|
||
{!data.comments.length && <li className="muted">Noch keine Wortmeldungen.</li>}
|
||
</ul>
|
||
</div>
|
||
<div className="mod-col">
|
||
<h2>Threads</h2>
|
||
<ul className="modlist">
|
||
{data.threads.map((t) => (
|
||
<li key={t.key} className={t.deleted ? 'gone' : ''}>
|
||
<div className="mod-head"><b>{t.title}</b>
|
||
<span className="muted"> · {t.forum_name} · {t.count}</span>
|
||
{t.locked && <span className="status">gesperrt</span>}
|
||
{t.deleted && <span className="status">ausgeblendet</span>}</div>
|
||
<div className="mod-actions">
|
||
<a href={t.url} target="_blank" rel="noreferrer">öffnen</a>
|
||
{t.kind !== 'library' && <button onClick={() => toggleLock(t)}>{t.locked ? 'Entsperren' : 'Sperren'}</button>}
|
||
{!t.deleted && <button onClick={() => delThread(t)}>Ausblenden</button>}
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function slugify(s) {
|
||
return (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||
}
|