-
-
Autor:innen & Rollen
+
+
Autor:innen & Rollen {list.length}
+ {list.length > 6 &&
setQ(e.target.value)} />}
-
User schreiben nur im Forum · Redakteur moderiert · Admin verwaltet alles. Admins aus ADMIN_EMAILS sind fix.
+
User schreiben im Forum · Redakteur moderiert · Admin verwaltet alles. Admins aus ADMIN_EMAILS sind fix.
);
}
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;
diff --git a/cms/admin/src/api.js b/cms/admin/src/api.js
index de2f45c..895c61c 100644
--- a/cms/admin/src/api.js
+++ b/cms/admin/src/api.js
@@ -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' }),
diff --git a/cms/admin/src/styles.css b/cms/admin/src/styles.css
index 1e99adb..ea4c908 100644
--- a/cms/admin/src/styles.css
+++ b/cms/admin/src/styles.css
@@ -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); }
diff --git a/cms/api/src/files.js b/cms/api/src/files.js
index 9bcc176..49b8b77 100644
--- a/cms/api/src/files.js
+++ b/cms/api/src/files.js
@@ -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 || '') ||
diff --git a/cms/api/src/index.js b/cms/api/src/index.js
index 23c5f74..81590aa 100644
--- a/cms/api/src/index.js
+++ b/cms/api/src/index.js
@@ -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/'));
diff --git a/cms/api/src/routes/stats.js b/cms/api/src/routes/stats.js
new file mode 100644
index 0000000..99a7a63
--- /dev/null
+++ b/cms/api/src/routes/stats.js
@@ -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;
diff --git a/cms/api/src/routes/users.js b/cms/api/src/routes/users.js
index 8b3e2bf..d2e025f 100644
--- a/cms/api/src/routes/users.js
+++ b/cms/api/src/routes/users.js
@@ -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 });
});