Files
kgva/cms/core/admin/src/App.jsx
T

733 lines
34 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &amp; 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 &amp; 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, '');
}