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:
2026-06-04 12:09:10 +02:00
parent d9ba2f7bbe
commit 9c9b7e03bd
7 changed files with 203 additions and 35 deletions
+5 -2
View File
@@ -37,6 +37,9 @@ function classify(rel) {
if (parts[0] === 'library' && parts.length === 3) {
return { kind: 'beitrag', section: parts[1] };
}
if (parts[0] === 'wiki') {
return { kind: 'wiki', section: 'wiki' };
}
return { kind: 'seite', section: null };
}
@@ -82,8 +85,8 @@ export async function listEntries() {
url: urlFor(rel),
});
}
// Beiträge zuerst, dann Seiten, dann Rubriken; je nach Datum/Titel.
const order = { beitrag: 0, seite: 1, rubrik: 2 };
// Beiträge zuerst, dann Wiki, Seiten, Rubriken; je nach Datum/Titel.
const order = { beitrag: 0, wiki: 1, seite: 2, rubrik: 3 };
items.sort((a, b) =>
(order[a.kind] - order[b.kind]) ||
(b.date || '').localeCompare(a.date || '') ||
+2
View File
@@ -11,6 +11,7 @@ import publish from './routes/publish.js';
import upload from './routes/upload.js';
import profile from './routes/profile.js';
import users from './routes/users.js';
import stats from './routes/stats.js';
import { listComments, createComment, deleteComment, login } from './routes/comments.js';
import history from './routes/history.js';
import {
@@ -91,6 +92,7 @@ app.route('/api/publish', publish);
app.route('/api/upload', upload);
app.route('/api/profile', profile);
app.route('/api/users', users);
app.route('/api/stats', stats);
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
app.get('/admin', (c) => c.redirect('/admin/'));
+47
View File
@@ -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;
+6 -2
View File
@@ -18,6 +18,7 @@ users.get('/', async (c) => {
id: u.id,
email: u.email,
created_at: u.created_at,
last_sign_in_at: u.last_sign_in_at || null,
role,
isAdmin: role === 'admin',
// Admins aus der .env lassen sich nicht per UI herabstufen.
@@ -28,9 +29,12 @@ users.get('/', 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);
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);
return c.json({ ok: true, id: data.user.id });
});