dialog: Diskussionsplattform mit Foren, Rollen & Moderation + RLS-Fix
Auth/RLS-Fix (Schreiben gab 400): - supabase.js: eigener supabaseAuth-Client für Login/Token-Check, damit signInWithPassword den Service-Daten-Client nicht prozessweit aufs User-Token umstellt (sonst lief insert als role=authenticated → RLS-Block). Rollen (admin > editor > user): - auth.js: roleOf() aus app_metadata.role + ADMIN_EMAILS, requireModerator. - users.js: Rolle anzeigen/setzen über GoTrue app_metadata; .env-Admins fix. Datenmodell (schema.sql): - forums (Kategorien) + threads; Seed Allgemein/Projekte/Technik/Off-Topic und Sonder-Kategorie Beiträge. Library-Beiträge werden als Threads gespiegelt (dialog-store.syncLibrary). API (routes/dialog.js, dialog-store.js): - öffentlich: /api/forums, /api/forums/:slug, /api/recent, /api/thread - eingeloggt: POST /api/threads (Thread starten, nur in Foren) - Moderation: /api/mod/* (sperren/ausblenden), Admin: /api/admin/forums CRUD - comments: Lock-Prüfung beim Schreiben, Moderation darf jede löschen. Frontend: - static/dialog.js: Router (Übersicht-Split-View | Forum | Thread), neuer Thread, Mod-Leiste, subtiles Login (dezente Zeile statt Formular). - Admin-UI: Tabs Foren + Moderation, Rollen-Dropdown bei Autor:innen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+156
-4
@@ -100,6 +100,8 @@ function Dashboard({ email }) {
|
||||
<nav className="nav">
|
||||
<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>}
|
||||
{me?.isAdmin && <button className={view === 'forums' ? 'active' : ''} onClick={() => setView('forums')}>Foren</button>}
|
||||
{me?.isAdmin && <button className={view === 'users' ? 'active' : ''} onClick={() => setView('users')}>Autor:innen</button>}
|
||||
</nav>
|
||||
<span className="spacer" />
|
||||
@@ -112,6 +114,10 @@ function Dashboard({ email }) {
|
||||
<Profile onMsg={setMsg} />
|
||||
) : view === 'users' ? (
|
||||
<Users onMsg={setMsg} currentEmail={me?.email} />
|
||||
) : view === 'forums' ? (
|
||||
<Forums onMsg={setMsg} />
|
||||
) : view === 'moderation' ? (
|
||||
<Moderation onMsg={setMsg} />
|
||||
) : (
|
||||
<>
|
||||
<aside>
|
||||
@@ -422,12 +428,16 @@ function Users({ onMsg, currentEmail }) {
|
||||
try { await api.setPassword(u.id, pw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); }
|
||||
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(); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
|
||||
if (!list) return <div className="empty">…</div>;
|
||||
return (
|
||||
<div className="profile">
|
||||
<div className="profile-card">
|
||||
<h2>Autor:innen</h2>
|
||||
<h2>Autor:innen & Rollen</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 />
|
||||
@@ -436,18 +446,160 @@ function Users({ onMsg, currentEmail }) {
|
||||
<ul className="userlist">
|
||||
{list.map((u) => (
|
||||
<li key={u.id}>
|
||||
<span className="t">{u.email}{u.isAdmin && <span className="status live">Admin</span>}</span>
|
||||
<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>
|
||||
{u.email !== currentEmail && <button onClick={() => remove(u)}>Löschen</button>}
|
||||
{u.email !== currentEmail && !u.fixedAdmin && <button onClick={() => remove(u)}>Löschen</button>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="muted who-mail">Admin-Rechte werden über <code>ADMIN_EMAILS</code> in der .env vergeben (nicht hier).</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ROLE_LABEL = { user: 'User', editor: 'Redakteur', admin: 'Admin' };
|
||||
|
||||
// ── 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, '');
|
||||
}
|
||||
|
||||
// ── Mapping Datei-Lesart → Formular ────────────────────────────────────────
|
||||
function fromRead(r) {
|
||||
const fm = r.frontmatter || {};
|
||||
|
||||
@@ -47,5 +47,18 @@ export const api = {
|
||||
listUsers: () => call('/users'),
|
||||
createUser: (email, password) => call('/users', { method: 'POST', body: JSON.stringify({ email, 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 }) }),
|
||||
deleteUser: (id) => call(`/users/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Foren-Verwaltung (Admin)
|
||||
listForumsAdmin: () => call('/admin/forums'),
|
||||
createForum: (f) => call('/admin/forums', { method: 'POST', body: JSON.stringify(f) }),
|
||||
updateForum: (id, f) => call(`/admin/forums/${id}`, { method: 'PUT', body: JSON.stringify(f) }),
|
||||
deleteForum: (id) => call(`/admin/forums/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Moderation (Admin + Redakteur)
|
||||
modOverview: () => call('/mod/overview'),
|
||||
lockThread: (key, locked) => call('/mod/thread-lock', { method: 'POST', body: JSON.stringify({ key, locked }) }),
|
||||
deleteThread: (key) => call('/mod/thread-delete', { method: 'POST', body: JSON.stringify({ key }) }),
|
||||
deleteComment: (id) => call(`/comments/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
@@ -158,6 +158,32 @@ label.big input { font-family: var(--serif); font-weight: 600; }
|
||||
.profile-card textarea { font-family: var(--serif); font-size: 15px; line-height: 1.6; resize: vertical; }
|
||||
.profile-card .actions { display: flex; }
|
||||
|
||||
.profile-card.wide { max-width: 760px; }
|
||||
.role-select { width: auto; height: 32px; padding: 4px 10px; font-size: 13px; }
|
||||
|
||||
/* ── Foren-Verwaltung ── */
|
||||
.forumlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.forumlist li { display: flex; align-items: center; gap: 10px; padding: 7px 12px; border: 1px solid var(--line); border-radius: 10px; }
|
||||
.forumlist li.is-library { background: var(--panel-2); }
|
||||
.forumlist .fsort { width: 2.2em; text-align: center; color: var(--muted); font-size: 12px; flex: none; }
|
||||
.forumlist .fname { flex: 1; height: 32px; }
|
||||
.forumlist .fcolor { width: 34px; height: 32px; padding: 2px; flex: none; }
|
||||
.forumlist .fslug { color: var(--muted); font-size: 12px; font-family: var(--mono, monospace); flex: none; }
|
||||
.forumlist button { padding: 5px 12px; font-size: 13px; }
|
||||
|
||||
/* ── Moderation (zweispaltig) ── */
|
||||
.moderation { width: 100%; overflow: auto; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 30px 24px; align-content: start; }
|
||||
.mod-col h2 { font-family: var(--serif); font-weight: 600; margin: 0 0 12px; }
|
||||
.modlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.modlist li { padding: 10px 13px; border: 1px solid var(--line); border-radius: 11px; background: var(--panel); }
|
||||
.modlist li.gone { opacity: .5; }
|
||||
.mod-head { font-size: 13.5px; }
|
||||
.mod-head .status { margin-left: 6px; padding: 1px 8px; background: rgba(184,144,47,.14); color: var(--amber); }
|
||||
.mod-body { font-family: var(--serif); font-size: 14.5px; margin: 6px 0; color: var(--text); }
|
||||
.mod-actions { display: flex; align-items: center; gap: 12px; font-size: 13px; }
|
||||
.mod-actions a { color: var(--muted); }
|
||||
.mod-actions button { padding: 4px 11px; 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