admin: Übersicht-Dashboard, aufgewertete Nutzerverwaltung, Wiki-Autoren
- Neue /api/stats (Admin, read-only): Inhalte/Nutzer/Dialog-Kennzahlen - Übersicht-View als Admin-Dashboard: Stat-Karten (klickbar) + Schnellzugriff - Nutzerverwaltung: Avatar-Initiale, angelegt/zuletzt-aktiv, Rolle beim Anlegen, Inline-Passwort (statt prompt), Filter, Rollen-Badge; API liefert last_sign_in_at - Wiki im Editor anlegbar: Typ 'Wiki-Seite' + Gruppe-Feld → content/wiki/<slug>.md; files.js klassifiziert wiki als eigene 'kind' (eigene Sidebar-Gruppe) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+110
-30
@@ -22,13 +22,13 @@ const hexOf = (name) => (COLORS.find((c) => c[0] === name) || [])[2] || 'transpa
|
|||||||
|
|
||||||
const LAYOUTS = ['', 'text', 'image', 'icon'];
|
const LAYOUTS = ['', 'text', 'image', 'icon'];
|
||||||
const SECTIONS = ['buerofuehrung', 'software', 'theorie'];
|
const SECTIONS = ['buerofuehrung', 'software', 'theorie'];
|
||||||
const KIND_LABEL = { beitrag: 'Beiträge', seite: 'Seiten', rubrik: 'Rubriken' };
|
const KIND_LABEL = { beitrag: 'Beiträge', wiki: 'Wiki', seite: 'Seiten', rubrik: 'Rubriken' };
|
||||||
|
|
||||||
const EMPTY = {
|
const EMPTY = {
|
||||||
isNew: true, path: '', type: 'beitrag', section: 'software', slug: '',
|
isNew: true, path: '', type: 'beitrag', section: 'software', slug: '',
|
||||||
title: '', date: new Date().toISOString().slice(0, 10), weight: '',
|
title: '', date: new Date().toISOString().slice(0, 10), weight: '',
|
||||||
color: '', layout: 'text', tags: '', summary: '', description: '',
|
color: '', layout: 'text', tags: '', summary: '', description: '',
|
||||||
cover_image: '', external: '', authors: '', toc: false, draft: true, body: '',
|
cover_image: '', external: '', authors: '', group: '', toc: false, draft: true, body: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -89,7 +89,7 @@ function Dashboard({ email }) {
|
|||||||
|
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const filtered = q ? entries.filter((e) => e.title.toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
|
const filtered = q ? entries.filter((e) => e.title.toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
|
||||||
const groups = { beitrag: [], seite: [], rubrik: [] };
|
const groups = { beitrag: [], wiki: [], seite: [], rubrik: [] };
|
||||||
for (const e of filtered) (groups[e.kind] || groups.seite).push(e);
|
for (const e of filtered) (groups[e.kind] || groups.seite).push(e);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -98,6 +98,7 @@ function Dashboard({ email }) {
|
|||||||
<span className="logo">OPENBUREAU</span>
|
<span className="logo">OPENBUREAU</span>
|
||||||
<span className="logo-sub">Redaktion</span>
|
<span className="logo-sub">Redaktion</span>
|
||||||
<nav className="nav">
|
<nav className="nav">
|
||||||
|
{me?.isAdmin && <button className={view === 'overview' ? 'active' : ''} onClick={() => setView('overview')}>Übersicht</button>}
|
||||||
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
|
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
|
||||||
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
|
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
|
||||||
{me?.canModerate && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
|
{me?.canModerate && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
|
||||||
@@ -110,7 +111,9 @@ function Dashboard({ email }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="body">
|
<div className="body">
|
||||||
{view === 'profile' ? (
|
{view === 'overview' ? (
|
||||||
|
<Overview onMsg={setMsg} go={setView} />
|
||||||
|
) : view === 'profile' ? (
|
||||||
<Profile onMsg={setMsg} />
|
<Profile onMsg={setMsg} />
|
||||||
) : view === 'users' ? (
|
) : view === 'users' ? (
|
||||||
<Users onMsg={setMsg} currentEmail={me?.email} />
|
<Users onMsg={setMsg} currentEmail={me?.email} />
|
||||||
@@ -123,7 +126,7 @@ function Dashboard({ email }) {
|
|||||||
<aside>
|
<aside>
|
||||||
<button className="new" onClick={() => setCurrent({ ...EMPTY })}>+ Neuer Beitrag</button>
|
<button className="new" onClick={() => setCurrent({ ...EMPTY })}>+ Neuer Beitrag</button>
|
||||||
<div className="search"><span>⌕</span><input placeholder="Suchen…" value={query} onChange={(e) => setQuery(e.target.value)} /></div>
|
<div className="search"><span>⌕</span><input placeholder="Suchen…" value={query} onChange={(e) => setQuery(e.target.value)} /></div>
|
||||||
{['beitrag', 'seite', 'rubrik'].map((kind) => groups[kind].length > 0 && (
|
{['beitrag', 'wiki', 'seite', 'rubrik'].map((kind) => groups[kind].length > 0 && (
|
||||||
<div className="group" key={kind}>
|
<div className="group" key={kind}>
|
||||||
<div className="group-title">{KIND_LABEL[kind]} <span>{groups[kind].length}</span></div>
|
<div className="group-title">{KIND_LABEL[kind]} <span>{groups[kind].length}</span></div>
|
||||||
<ul className="list">
|
<ul className="list">
|
||||||
@@ -166,6 +169,7 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
const dragging = useRef(false);
|
const dragging = useRef(false);
|
||||||
const coverIn = useRef(null);
|
const coverIn = useRef(null);
|
||||||
const set = (k) => (e) => setF({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value });
|
const set = (k) => (e) => setF({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value });
|
||||||
|
const isWiki = f.type === 'wiki' || (f.path || '').startsWith('wiki/');
|
||||||
|
|
||||||
async function pickCover(ev) {
|
async function pickCover(ev) {
|
||||||
const file = ev.target.files?.[0]; ev.target.value = '';
|
const file = ev.target.files?.[0]; ev.target.value = '';
|
||||||
@@ -194,7 +198,9 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
if (!data.isNew) return data.path;
|
if (!data.isNew) return data.path;
|
||||||
const slug = (data.slug || '').trim();
|
const slug = (data.slug || '').trim();
|
||||||
if (!slug) return '';
|
if (!slug) return '';
|
||||||
return data.type === 'beitrag' ? `library/${data.section}/${slug}.md` : `${slug}.md`;
|
if (data.type === 'beitrag') return `library/${data.section}/${slug}.md`;
|
||||||
|
if (data.type === 'wiki') return `wiki/${slug}.md`;
|
||||||
|
return `${slug}.md`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// overrides erlauben z.B. { draft: false } beim Publizieren.
|
// overrides erlauben z.B. { draft: false } beim Publizieren.
|
||||||
@@ -250,6 +256,7 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
<label className="sm">Typ
|
<label className="sm">Typ
|
||||||
<select value={f.type} onChange={set('type')}>
|
<select value={f.type} onChange={set('type')}>
|
||||||
<option value="beitrag">Beitrag</option>
|
<option value="beitrag">Beitrag</option>
|
||||||
|
<option value="wiki">Wiki-Seite</option>
|
||||||
<option value="seite">Seite</option>
|
<option value="seite">Seite</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -265,6 +272,7 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
<label className="big">Titel<input value={f.title} onChange={set('title')} placeholder="Titel des Beitrags" /></label>
|
<label className="big">Titel<input value={f.title} onChange={set('title')} placeholder="Titel des Beitrags" /></label>
|
||||||
|
|
||||||
<div className="meta">
|
<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="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="xs">Reihenfolge<input type="number" value={f.weight} onChange={set('weight')} placeholder="weight" /></label>
|
||||||
<label className="sm">Farbe
|
<label className="sm">Farbe
|
||||||
@@ -398,12 +406,55 @@ function Profile({ onMsg }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ü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}</span>
|
||||||
|
<span className="stat-label">{label}</span>
|
||||||
|
<span className="stat-hint">{hint || ' '}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
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="Wiki-Seiten" value={s.content.wiki} 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" />
|
||||||
|
</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>
|
||||||
|
<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="/wiki/" target="_blank" rel="noreferrer">Wiki ↗</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
|
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
|
||||||
function Users({ onMsg, currentEmail }) {
|
function Users({ onMsg, currentEmail }) {
|
||||||
const [list, setList] = useState(null);
|
const [list, setList] = useState(null);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [role, setRole] = useState('user');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [pwFor, setPwFor] = useState(null);
|
||||||
|
const [newPw, setNewPw] = useState('');
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try { setList(await api.listUsers()); }
|
try { setList(await api.listUsers()); }
|
||||||
@@ -413,59 +464,85 @@ function Users({ onMsg, currentEmail }) {
|
|||||||
|
|
||||||
async function create(e) {
|
async function create(e) {
|
||||||
e.preventDefault(); setBusy(true);
|
e.preventDefault(); setBusy(true);
|
||||||
try { await api.createUser(email, password); onMsg({ type: 'ok', text: 'Autor:in angelegt.' }); setEmail(''); setPassword(''); refresh(); }
|
try {
|
||||||
catch (err) { onMsg({ type: 'err', text: err.message }); }
|
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); }
|
finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
async function remove(u) {
|
async function remove(u) {
|
||||||
if (!confirm(`${u.email} löschen?`)) return;
|
if (!confirm(`${u.email} wirklich löschen?`)) return;
|
||||||
try { await api.deleteUser(u.id); refresh(); }
|
try { await api.deleteUser(u.id); refresh(); }
|
||||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
}
|
}
|
||||||
async function reset(u) {
|
async function savePw(u) {
|
||||||
const pw = prompt(`Neues Passwort für ${u.email}:`);
|
if (!newPw || newPw.length < 6) { onMsg({ type: 'err', text: 'Passwort zu kurz (min. 6 Zeichen).' }); return; }
|
||||||
if (!pw) return;
|
try { await api.setPassword(u.id, newPw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); setPwFor(null); setNewPw(''); }
|
||||||
try { await api.setPassword(u.id, pw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); }
|
|
||||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
}
|
}
|
||||||
async function changeRole(u, role) {
|
async function changeRole(u, r) {
|
||||||
try { await api.setRole(u.id, role); onMsg({ type: 'ok', text: `Rolle: ${ROLE_LABEL[role]}` }); refresh(); }
|
try { await api.setRole(u.id, r); onMsg({ type: 'ok', text: `Rolle: ${ROLE_LABEL[r]}` }); refresh(); }
|
||||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!list) return <div className="empty">…</div>;
|
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 (
|
return (
|
||||||
<div className="profile">
|
<div className="profile">
|
||||||
<div className="profile-card">
|
<div className="profile-card wide">
|
||||||
<h2>Autor:innen & Rollen</h2>
|
<h2>Autor:innen & Rollen <span className="count-pill">{list.length}</span></h2>
|
||||||
<form className="userform" onSubmit={create}>
|
<form className="userform" onSubmit={create}>
|
||||||
<input type="email" placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
<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 />
|
<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>
|
<button className="primary" disabled={busy}>Anlegen</button>
|
||||||
</form>
|
</form>
|
||||||
|
{list.length > 6 && <input className="userfilter" placeholder="filtern…" value={q} onChange={(e) => setQ(e.target.value)} />}
|
||||||
<ul className="userlist">
|
<ul className="userlist">
|
||||||
{list.map((u) => (
|
{filtered.map((u) => (
|
||||||
<li key={u.id}>
|
<li key={u.id}>
|
||||||
<span className="t">{u.email}</span>
|
<span className="uavatar" style={avatarStyle(u.email)}>{(u.email || '?').slice(0, 1).toUpperCase()}</span>
|
||||||
{u.fixedAdmin
|
<span className="t ucol">
|
||||||
? <span className="status live">Admin (.env)</span>
|
<span className="uemail">{u.email}{u.email === currentEmail && <span className="you"> · du</span>}</span>
|
||||||
: <select className="role-select" value={u.role} onChange={(e) => changeRole(u, e.target.value)}>
|
<span className="umeta">
|
||||||
<option value="user">User</option>
|
angelegt {fmtDate(u.created_at)}
|
||||||
<option value="editor">Redakteur</option>
|
{u.last_sign_in_at ? ` · zuletzt aktiv ${fmtDate(u.last_sign_in_at)}` : ' · nie angemeldet'}
|
||||||
<option value="admin">Admin</option>
|
</span>
|
||||||
</select>}
|
</span>
|
||||||
<button onClick={() => reset(u)}>Passwort</button>
|
{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>}
|
{u.email !== currentEmail && !u.fixedAdmin && <button onClick={() => remove(u)}>Löschen</button>}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<p className="muted who-mail"><b>User</b> schreiben nur im Forum · <b>Redakteur</b> moderiert · <b>Admin</b> verwaltet alles. Admins aus <code>ADMIN_EMAILS</code> sind fix.</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_LABEL = { user: 'User', editor: 'Redakteur', admin: 'Admin' };
|
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) ────────────────────────────────────────────
|
// ── Foren-Verwaltung (nur Admin) ────────────────────────────────────────────
|
||||||
function Forums({ onMsg }) {
|
function Forums({ onMsg }) {
|
||||||
@@ -603,15 +680,17 @@ function slugify(s) {
|
|||||||
// ── Mapping Datei-Lesart → Formular ────────────────────────────────────────
|
// ── Mapping Datei-Lesart → Formular ────────────────────────────────────────
|
||||||
function fromRead(r) {
|
function fromRead(r) {
|
||||||
const fm = r.frontmatter || {};
|
const fm = r.frontmatter || {};
|
||||||
|
const p = r.path || '';
|
||||||
|
const type = p.startsWith('library/') ? 'beitrag' : p.startsWith('wiki/') ? 'wiki' : 'seite';
|
||||||
return {
|
return {
|
||||||
isNew: false, path: r.path, type: 'beitrag', section: '', slug: '',
|
isNew: false, path: r.path, type, section: '', slug: '',
|
||||||
title: fm.title || '', date: fm.date ? String(fm.date).slice(0, 10) : '',
|
title: fm.title || '', date: fm.date ? String(fm.date).slice(0, 10) : '',
|
||||||
weight: fm.weight ?? '', color: fm.color || '', layout: fm.layout || '',
|
weight: fm.weight ?? '', color: fm.color || '', layout: fm.layout || '',
|
||||||
tags: Array.isArray(fm.tags) ? fm.tags.join(', ') : '',
|
tags: Array.isArray(fm.tags) ? fm.tags.join(', ') : '',
|
||||||
summary: fm.summary || '', description: fm.description || '',
|
summary: fm.summary || '', description: fm.description || '',
|
||||||
cover_image: fm.cover_image || '', external: fm.external || '',
|
cover_image: fm.cover_image || '', external: fm.external || '',
|
||||||
authors: Array.isArray(fm.authors) ? fm.authors.join(', ') : (fm.authors || ''),
|
authors: Array.isArray(fm.authors) ? fm.authors.join(', ') : (fm.authors || ''),
|
||||||
toc: !!fm.toc, draft: !!fm.draft, body: r.body || '',
|
group: fm.group || '', toc: !!fm.toc, draft: !!fm.draft, body: r.body || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function buildFrontmatter(f) {
|
function buildFrontmatter(f) {
|
||||||
@@ -628,6 +707,7 @@ function buildFrontmatter(f) {
|
|||||||
if (f.color) fm.color = f.color;
|
if (f.color) fm.color = f.color;
|
||||||
const authors = f.authors ? f.authors.split(',').map((t) => t.trim()).filter(Boolean) : [];
|
const authors = f.authors ? f.authors.split(',').map((t) => t.trim()).filter(Boolean) : [];
|
||||||
if (authors.length) fm.authors = authors;
|
if (authors.length) fm.authors = authors;
|
||||||
|
if (f.group) fm.group = f.group;
|
||||||
if (f.toc) fm.toc = true;
|
if (f.toc) fm.toc = true;
|
||||||
if (f.draft) fm.draft = true;
|
if (f.draft) fm.draft = true;
|
||||||
return fm;
|
return fm;
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ export const api = {
|
|||||||
getProfile: () => call('/profile'),
|
getProfile: () => call('/profile'),
|
||||||
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
|
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
|
||||||
getMe: () => call('/me'),
|
getMe: () => call('/me'),
|
||||||
|
stats: () => call('/stats'),
|
||||||
listUsers: () => call('/users'),
|
listUsers: () => call('/users'),
|
||||||
createUser: (email, password) => call('/users', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
createUser: (email, password, role) => call('/users', { method: 'POST', body: JSON.stringify({ email, password, role }) }),
|
||||||
setPassword: (id, password) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ password }) }),
|
setPassword: (id, password) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ password }) }),
|
||||||
setRole: (id, role) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
setRole: (id, role) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
||||||
deleteUser: (id) => call(`/users/${id}`, { method: 'DELETE' }),
|
deleteUser: (id) => call(`/users/${id}`, { method: 'DELETE' }),
|
||||||
|
|||||||
@@ -184,6 +184,37 @@ label.big input { font-family: var(--serif); font-weight: 600; }
|
|||||||
.mod-actions a { color: var(--muted); }
|
.mod-actions a { color: var(--muted); }
|
||||||
.mod-actions button { padding: 4px 11px; font-size: 12.5px; }
|
.mod-actions button { padding: 4px 11px; font-size: 12.5px; }
|
||||||
|
|
||||||
|
/* ── Übersicht / Dashboard ── */
|
||||||
|
.overview { width: 100%; overflow: auto; padding: 30px 28px; }
|
||||||
|
.overview h2 { font-family: var(--serif); font-weight: 600; margin: 0 0 18px; }
|
||||||
|
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
|
||||||
|
.stat-card { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left;
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow); }
|
||||||
|
.stat-card:not(:disabled):hover { border-color: var(--accent-soft); transform: translateY(-1px); }
|
||||||
|
.stat-card:disabled { opacity: 1; cursor: default; }
|
||||||
|
.stat-value { font-family: var(--display); font-weight: 700; font-size: 30px; line-height: 1; color: var(--accent); }
|
||||||
|
.stat-label { font-family: var(--serif); font-size: 15px; margin-top: 6px; }
|
||||||
|
.stat-hint { font-size: 11.5px; color: var(--muted); min-height: 1em; }
|
||||||
|
.overview-actions { margin-top: 30px; }
|
||||||
|
.overview-actions h3 { font-family: var(--display); font-size: 12px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin: 0 0 10px; }
|
||||||
|
.quick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||||
|
.quick-link { display: inline-flex; align-items: center; padding: 8px 16px; border: 1px solid var(--line); border-radius: var(--pill); text-decoration: none; color: var(--muted); }
|
||||||
|
.quick-link:hover { border-color: var(--accent-soft); color: var(--text); }
|
||||||
|
|
||||||
|
/* ── Nutzerliste (aufgewertet) ── */
|
||||||
|
.count-pill { font-family: var(--sans); font-size: 12px; font-weight: 500; color: var(--muted); background: var(--panel-2); border-radius: 20px; padding: 2px 9px; vertical-align: middle; margin-left: 6px; }
|
||||||
|
.userfilter { margin: 4px 0 2px; height: 34px; }
|
||||||
|
.userlist .uavatar { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-weight: 600; font-size: 13px; flex: none; }
|
||||||
|
.userlist .ucol { flex-direction: column; align-items: flex-start; gap: 1px; min-width: 0; }
|
||||||
|
.uemail { font-family: var(--serif); font-size: 14.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
|
||||||
|
.uemail .you { color: var(--accent); font-family: var(--sans); font-size: 12px; }
|
||||||
|
.umeta { font-size: 11.5px; color: var(--muted); }
|
||||||
|
.rolebadge { font-size: 11px; border-radius: var(--pill); padding: 3px 10px; font-weight: 600; flex: none; }
|
||||||
|
.rolebadge.admin { color: var(--accent); background: rgba(181,74,44,.12); }
|
||||||
|
.pwinline { display: flex; align-items: center; gap: 5px; flex: none; }
|
||||||
|
.pwinline input { width: 150px; height: 30px; }
|
||||||
|
.pwinline button { padding: 4px 10px; font-size: 12.5px; }
|
||||||
|
|
||||||
/* ── Toast ── */
|
/* ── Toast ── */
|
||||||
.toast { position: fixed; bottom: 20px; right: 20px; padding: 11px 18px; border-radius: 11px; color: #fff; cursor: pointer; box-shadow: 0 10px 30px -12px rgba(0,0,0,.4); font-size: 13.5px; max-width: 380px; z-index: 50; }
|
.toast { position: fixed; bottom: 20px; right: 20px; padding: 11px 18px; border-radius: 11px; color: #fff; cursor: pointer; box-shadow: 0 10px 30px -12px rgba(0,0,0,.4); font-size: 13.5px; max-width: 380px; z-index: 50; }
|
||||||
.toast.ok { background: var(--ok); }
|
.toast.ok { background: var(--ok); }
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ function classify(rel) {
|
|||||||
if (parts[0] === 'library' && parts.length === 3) {
|
if (parts[0] === 'library' && parts.length === 3) {
|
||||||
return { kind: 'beitrag', section: parts[1] };
|
return { kind: 'beitrag', section: parts[1] };
|
||||||
}
|
}
|
||||||
|
if (parts[0] === 'wiki') {
|
||||||
|
return { kind: 'wiki', section: 'wiki' };
|
||||||
|
}
|
||||||
return { kind: 'seite', section: null };
|
return { kind: 'seite', section: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,8 +85,8 @@ export async function listEntries() {
|
|||||||
url: urlFor(rel),
|
url: urlFor(rel),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Beiträge zuerst, dann Seiten, dann Rubriken; je nach Datum/Titel.
|
// Beiträge zuerst, dann Wiki, Seiten, Rubriken; je nach Datum/Titel.
|
||||||
const order = { beitrag: 0, seite: 1, rubrik: 2 };
|
const order = { beitrag: 0, wiki: 1, seite: 2, rubrik: 3 };
|
||||||
items.sort((a, b) =>
|
items.sort((a, b) =>
|
||||||
(order[a.kind] - order[b.kind]) ||
|
(order[a.kind] - order[b.kind]) ||
|
||||||
(b.date || '').localeCompare(a.date || '') ||
|
(b.date || '').localeCompare(a.date || '') ||
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import publish from './routes/publish.js';
|
|||||||
import upload from './routes/upload.js';
|
import upload from './routes/upload.js';
|
||||||
import profile from './routes/profile.js';
|
import profile from './routes/profile.js';
|
||||||
import users from './routes/users.js';
|
import users from './routes/users.js';
|
||||||
|
import stats from './routes/stats.js';
|
||||||
import { listComments, createComment, deleteComment, login } from './routes/comments.js';
|
import { listComments, createComment, deleteComment, login } from './routes/comments.js';
|
||||||
import history from './routes/history.js';
|
import history from './routes/history.js';
|
||||||
import {
|
import {
|
||||||
@@ -91,6 +92,7 @@ app.route('/api/publish', publish);
|
|||||||
app.route('/api/upload', upload);
|
app.route('/api/upload', upload);
|
||||||
app.route('/api/profile', profile);
|
app.route('/api/profile', profile);
|
||||||
app.route('/api/users', users);
|
app.route('/api/users', users);
|
||||||
|
app.route('/api/stats', stats);
|
||||||
|
|
||||||
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
|
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
|
||||||
app.get('/admin', (c) => c.redirect('/admin/'));
|
app.get('/admin', (c) => c.redirect('/admin/'));
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { supabase } from '../supabase.js';
|
||||||
|
import { listEntries } from '../files.js';
|
||||||
|
import { requireAdmin, roleOf } from '../auth.js';
|
||||||
|
|
||||||
|
// Kennzahlen für die Admin-Übersicht. Nur Admins; rein lesend.
|
||||||
|
const stats = new Hono();
|
||||||
|
stats.use('*', requireAdmin);
|
||||||
|
|
||||||
|
stats.get('/', async (c) => {
|
||||||
|
// Inhalte aus dem Dateisystem zählen.
|
||||||
|
const content = { beitraege: 0, entwuerfe: 0, wiki: 0, seiten: 0, rubriken: 0 };
|
||||||
|
try {
|
||||||
|
for (const e of await listEntries()) {
|
||||||
|
if (e.kind === 'beitrag') { content.beitraege++; if (e.draft) content.entwuerfe++; }
|
||||||
|
else if (e.kind === 'wiki') content.wiki++;
|
||||||
|
else if (e.kind === 'rubrik') content.rubriken++;
|
||||||
|
else content.seiten++;
|
||||||
|
}
|
||||||
|
} catch { /* Filesystem nicht lesbar → 0 */ }
|
||||||
|
|
||||||
|
// Nutzer nach Rolle.
|
||||||
|
const users = { total: 0, admin: 0, editor: 0, user: 0 };
|
||||||
|
try {
|
||||||
|
const { data } = await supabase.auth.admin.listUsers();
|
||||||
|
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; }
|
||||||
|
} catch { /* GoTrue nicht erreichbar */ }
|
||||||
|
|
||||||
|
// Dialog-Zähler (effizient: head + count, keine Zeilen laden).
|
||||||
|
const count = async (table, filter) => {
|
||||||
|
try {
|
||||||
|
let q = supabase.from(table).select('*', { count: 'exact', head: true });
|
||||||
|
if (filter) q = filter(q);
|
||||||
|
const { count: n } = await q;
|
||||||
|
return n || 0;
|
||||||
|
} catch { return 0; }
|
||||||
|
};
|
||||||
|
const [forums, threads, comments] = await Promise.all([
|
||||||
|
count('forums'),
|
||||||
|
count('threads', (q) => q.eq('deleted', false)),
|
||||||
|
count('comments', (q) => q.eq('deleted', false)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return c.json({ content, users, dialog: { forums, threads, comments } });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default stats;
|
||||||
@@ -18,6 +18,7 @@ users.get('/', async (c) => {
|
|||||||
id: u.id,
|
id: u.id,
|
||||||
email: u.email,
|
email: u.email,
|
||||||
created_at: u.created_at,
|
created_at: u.created_at,
|
||||||
|
last_sign_in_at: u.last_sign_in_at || null,
|
||||||
role,
|
role,
|
||||||
isAdmin: role === 'admin',
|
isAdmin: role === 'admin',
|
||||||
// Admins aus der .env lassen sich nicht per UI herabstufen.
|
// Admins aus der .env lassen sich nicht per UI herabstufen.
|
||||||
@@ -28,9 +29,12 @@ users.get('/', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
users.post('/', async (c) => {
|
users.post('/', async (c) => {
|
||||||
const { email, password } = await c.req.json();
|
const { email, password, role } = await c.req.json();
|
||||||
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
||||||
const { data, error } = await supabase.auth.admin.createUser({ email, password, email_confirm: true });
|
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
|
||||||
|
const payload = { email, password, email_confirm: true };
|
||||||
|
if (role && role !== 'user') payload.app_metadata = { role };
|
||||||
|
const { data, error } = await supabase.auth.admin.createUser(payload);
|
||||||
if (error) return c.json({ error: error.message }, 400);
|
if (error) return c.json({ error: error.message }, 400);
|
||||||
return c.json({ ok: true, id: data.user.id });
|
return c.json({ ok: true, id: data.user.id });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user