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 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 = {
|
||||
isNew: true, path: '', type: 'beitrag', section: 'software', slug: '',
|
||||
title: '', date: new Date().toISOString().slice(0, 10), weight: '',
|
||||
color: '', layout: 'text', tags: '', summary: '', description: '',
|
||||
cover_image: '', external: '', authors: '', toc: false, draft: true, body: '',
|
||||
cover_image: '', external: '', authors: '', group: '', toc: false, draft: true, body: '',
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
@@ -89,7 +89,7 @@ function Dashboard({ email }) {
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
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);
|
||||
|
||||
return (
|
||||
@@ -98,6 +98,7 @@ function Dashboard({ email }) {
|
||||
<span className="logo">OPENBUREAU</span>
|
||||
<span className="logo-sub">Redaktion</span>
|
||||
<nav className="nav">
|
||||
{me?.isAdmin && <button className={view === 'overview' ? 'active' : ''} onClick={() => setView('overview')}>Übersicht</button>}
|
||||
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
|
||||
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
|
||||
{me?.canModerate && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
|
||||
@@ -110,7 +111,9 @@ function Dashboard({ email }) {
|
||||
</header>
|
||||
|
||||
<div className="body">
|
||||
{view === 'profile' ? (
|
||||
{view === 'overview' ? (
|
||||
<Overview onMsg={setMsg} go={setView} />
|
||||
) : view === 'profile' ? (
|
||||
<Profile onMsg={setMsg} />
|
||||
) : view === 'users' ? (
|
||||
<Users onMsg={setMsg} currentEmail={me?.email} />
|
||||
@@ -123,7 +126,7 @@ function Dashboard({ email }) {
|
||||
<aside>
|
||||
<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>
|
||||
{['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-title">{KIND_LABEL[kind]} <span>{groups[kind].length}</span></div>
|
||||
<ul className="list">
|
||||
@@ -166,6 +169,7 @@ function Editor({ initial, onSaved, onMsg }) {
|
||||
const dragging = useRef(false);
|
||||
const coverIn = useRef(null);
|
||||
const set = (k) => (e) => setF({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value });
|
||||
const isWiki = f.type === 'wiki' || (f.path || '').startsWith('wiki/');
|
||||
|
||||
async function pickCover(ev) {
|
||||
const file = ev.target.files?.[0]; ev.target.value = '';
|
||||
@@ -194,7 +198,9 @@ function Editor({ initial, onSaved, onMsg }) {
|
||||
if (!data.isNew) return data.path;
|
||||
const slug = (data.slug || '').trim();
|
||||
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.
|
||||
@@ -250,6 +256,7 @@ function Editor({ initial, onSaved, onMsg }) {
|
||||
<label className="sm">Typ
|
||||
<select value={f.type} onChange={set('type')}>
|
||||
<option value="beitrag">Beitrag</option>
|
||||
<option value="wiki">Wiki-Seite</option>
|
||||
<option value="seite">Seite</option>
|
||||
</select>
|
||||
</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>
|
||||
|
||||
<div className="meta">
|
||||
{isWiki && <label className="sm">Gruppe<input value={f.group} onChange={set('group')} placeholder="z. B. Begriffe" /></label>}
|
||||
<label className="sm">Datum<input type="date" value={f.date} onChange={set('date')} /></label>
|
||||
<label className="xs">Reihenfolge<input type="number" value={f.weight} onChange={set('weight')} placeholder="weight" /></label>
|
||||
<label className="sm">Farbe
|
||||
@@ -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) ──────────────────────────────────────
|
||||
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()); }
|
||||
@@ -413,59 +464,85 @@ function Users({ onMsg, currentEmail }) {
|
||||
|
||||
async function create(e) {
|
||||
e.preventDefault(); setBusy(true);
|
||||
try { await api.createUser(email, password); onMsg({ type: 'ok', text: 'Autor:in angelegt.' }); setEmail(''); setPassword(''); refresh(); }
|
||||
catch (err) { onMsg({ type: 'err', text: err.message }); }
|
||||
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} löschen?`)) return;
|
||||
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 reset(u) {
|
||||
const pw = prompt(`Neues Passwort für ${u.email}:`);
|
||||
if (!pw) return;
|
||||
try { await api.setPassword(u.id, pw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); }
|
||||
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, role) {
|
||||
try { await api.setRole(u.id, role); onMsg({ type: 'ok', text: `Rolle: ${ROLE_LABEL[role]}` }); refresh(); }
|
||||
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">
|
||||
<h2>Autor:innen & Rollen</h2>
|
||||
<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">
|
||||
{list.map((u) => (
|
||||
{filtered.map((u) => (
|
||||
<li key={u.id}>
|
||||
<span className="t">{u.email}</span>
|
||||
{u.fixedAdmin
|
||||
? <span className="status live">Admin (.env)</span>
|
||||
: <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>}
|
||||
<button onClick={() => reset(u)}>Passwort</button>
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
@@ -603,15 +680,17 @@ function slugify(s) {
|
||||
// ── Mapping Datei-Lesart → Formular ────────────────────────────────────────
|
||||
function fromRead(r) {
|
||||
const fm = r.frontmatter || {};
|
||||
const p = r.path || '';
|
||||
const type = p.startsWith('library/') ? 'beitrag' : p.startsWith('wiki/') ? 'wiki' : 'seite';
|
||||
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) : '',
|
||||
weight: fm.weight ?? '', color: fm.color || '', layout: fm.layout || '',
|
||||
tags: Array.isArray(fm.tags) ? fm.tags.join(', ') : '',
|
||||
summary: fm.summary || '', description: fm.description || '',
|
||||
cover_image: fm.cover_image || '', external: fm.external || '',
|
||||
authors: Array.isArray(fm.authors) ? fm.authors.join(', ') : (fm.authors || ''),
|
||||
toc: !!fm.toc, draft: !!fm.draft, body: r.body || '',
|
||||
group: fm.group || '', toc: !!fm.toc, draft: !!fm.draft, body: r.body || '',
|
||||
};
|
||||
}
|
||||
function buildFrontmatter(f) {
|
||||
@@ -628,6 +707,7 @@ function buildFrontmatter(f) {
|
||||
if (f.color) fm.color = f.color;
|
||||
const authors = f.authors ? f.authors.split(',').map((t) => t.trim()).filter(Boolean) : [];
|
||||
if (authors.length) fm.authors = authors;
|
||||
if (f.group) fm.group = f.group;
|
||||
if (f.toc) fm.toc = true;
|
||||
if (f.draft) fm.draft = true;
|
||||
return fm;
|
||||
|
||||
@@ -44,8 +44,9 @@ export const api = {
|
||||
getProfile: () => call('/profile'),
|
||||
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
|
||||
getMe: () => call('/me'),
|
||||
stats: () => call('/stats'),
|
||||
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 }) }),
|
||||
setRole: (id, role) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
||||
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 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 { 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); }
|
||||
|
||||
Reference in New Issue
Block a user