Files
OPENBUREAU/static/dialog.js
T
karim bd85570259 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>
2026-05-31 16:09:19 +02:00

298 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* OPENBUREAU Dialog — Foren, Threads & flache Wortmeldungen.
Lesen öffentlich; Mitreden nach Login. Kein Build nötig.
Routen über Query-Parameter:
/dialog/ → Übersicht (Split-View: letzte Beiträge | Foren)
/dialog/?forum=<slug> → ein Forum mit seinen Threads
/dialog/?thread=<key> → ein Thread mit Wortmeldungen
*/
(function () {
const root = document.getElementById('ob-dialog');
if (!root) return;
const ctxEl = document.getElementById('dialog-context');
const TKEY = 'ob_dialog_token', NKEY = 'ob_dialog_name', RKEY = 'ob_dialog_role';
let token = localStorage.getItem(TKEY);
let myName = localStorage.getItem(NKEY);
let myRole = localStorage.getItem(RKEY) || 'user';
const canModerate = () => token && (myRole === 'admin' || myRole === 'editor');
const params = new URLSearchParams(location.search);
const threadKey = params.get('thread');
const forumSlug = params.get('forum');
const el = (tag, cls, txt) => { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; };
function fmt(ts) {
const d = new Date(ts), s = (Date.now() - d) / 1000;
if (s < 60) return 'gerade eben';
if (s < 3600) return Math.floor(s / 60) + ' Min.';
if (s < 86400) return Math.floor(s / 3600) + ' Std.';
return d.toLocaleDateString('de-CH');
}
const api = (p, opt) => fetch(p, opt).then(async (r) => ({ ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }));
const authHdr = () => ({ Authorization: 'Bearer ' + token });
// ── Auth ────────────────────────────────────────────────────────────────
async function doLogin(email, password, after) {
const r = await api('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) });
if (!r.ok) { alert(r.body.error || 'Login fehlgeschlagen'); return; }
token = r.body.access_token; myName = r.body.name || ''; myRole = r.body.role || 'user';
localStorage.setItem(TKEY, token); localStorage.setItem(NKEY, myName); localStorage.setItem(RKEY, myRole);
if (after) after();
}
function logout(after) {
token = null; myName = null; myRole = 'user';
localStorage.removeItem(TKEY); localStorage.removeItem(NKEY); localStorage.removeItem(RKEY);
if (after) after();
}
// Subtiles Login: dezente Zeile, klappt zum kompakten Formular auf.
function loginInline(container, after, label) {
let open = false;
function paint() {
container.innerHTML = '';
if (!open) {
const link = el('button', 'dialog-loginlink', label || 'Zum Mitreden anmelden');
link.onclick = () => { open = true; paint(); };
container.appendChild(link);
} else {
const form = el('div', 'dialog-loginform');
const em = el('input', 'dialog-input'); em.type = 'email'; em.placeholder = 'E-Mail';
const pw = el('input', 'dialog-input'); pw.type = 'password'; pw.placeholder = 'Passwort';
const btn = el('button', 'dialog-send', 'Anmelden');
btn.onclick = () => doLogin(em.value, pw.value, after);
pw.onkeydown = (e) => { if (e.key === 'Enter') doLogin(em.value, pw.value, after); };
const cancel = el('button', 'dialog-loginlink dialog-logincancel', 'Abbrechen');
cancel.onclick = () => { open = false; paint(); };
form.append(em, pw, btn, cancel); container.appendChild(form); em.focus();
}
}
paint();
}
// ── Übersicht: Split-View ─────────────────────────────────────────────────
function renderOverview() {
if (ctxEl) ctxEl.textContent = '';
root.innerHTML = '';
const grid = el('div', 'dialog-split');
const left = el('div', 'dialog-recent');
const right = el('div', 'dialog-forums');
left.appendChild(el('h2', 'dialog-title', 'Letzte Wortmeldungen'));
right.appendChild(el('h2', 'dialog-title', 'Foren'));
grid.append(left, right); root.appendChild(grid);
api('/api/recent?limit=15').then((r) => {
const rows = r.body || [];
if (!rows.length) { left.appendChild(el('p', 'dialog-empty', 'Noch keine Wortmeldungen.')); return; }
const list = el('div', 'dialog-recent-list');
rows.forEach((c) => {
const a = el('a', 'dialog-recent-item'); a.href = c.thread_url;
const top = el('div', 'dialog-recent-top');
top.append(el('span', 'dialog-recent-author', c.author_name || 'Unbekannt'),
el('span', 'dialog-recent-meta', (c.forum_name ? c.forum_name + ' · ' : '') + fmt(c.created_at)));
const tt = el('div', 'dialog-recent-thread', c.thread_title);
const bd = el('div', 'dialog-recent-body', c.body);
a.append(top, tt, bd); list.appendChild(a);
});
left.appendChild(list);
});
api('/api/forums').then((r) => {
const rows = r.body || [];
const list = el('div', 'dialog-forum-list');
rows.forEach((f) => {
const a = el('a', 'dialog-forum-item'); a.href = '/dialog/?forum=' + encodeURIComponent(f.slug);
if (f.color) a.style.setProperty('--forum-accent', f.color);
const nm = el('span', 'dialog-forum-name', f.name);
const mt = el('span', 'dialog-forum-meta',
f.thread_count + (f.thread_count === 1 ? ' Thread' : ' Threads') + ' · ' + f.post_count + ' Beiträge');
a.append(nm, mt);
if (f.description) a.appendChild(el('span', 'dialog-forum-desc', f.description));
list.appendChild(a);
});
right.appendChild(list);
});
}
// ── Forum-Ansicht: Threads + neuer Thread ─────────────────────────────────
function renderForum(slug) {
root.innerHTML = '';
if (ctxEl) { ctxEl.innerHTML = ''; const b = el('a', null, '← Dialoge'); b.href = '/dialog/'; ctxEl.appendChild(b); }
api('/api/forums/' + encodeURIComponent(slug)).then((r) => {
if (!r.ok) { root.appendChild(el('p', 'dialog-empty', 'Forum nicht gefunden.')); return; }
const { forum, threads } = r.body;
const head = el('div', 'dialog-forum-head');
head.appendChild(el('h2', 'dialog-title', forum.name));
if (forum.color) head.style.setProperty('--forum-accent', forum.color);
root.appendChild(head);
if (forum.description) root.appendChild(el('p', 'dialog-forum-desc', forum.description));
// Neuer Thread (nur Forum-Kategorien, nicht „Beiträge").
if (forum.kind !== 'library') {
const newBox = el('div', 'dialog-newthread');
root.appendChild(newBox);
renderNewThread(newBox, forum.slug);
}
const list = el('div', 'dialog-thread-list');
if (!threads.length) list.appendChild(el('p', 'dialog-empty', 'Noch keine Threads — beginne einen.'));
threads.forEach((t) => {
const a = el('a', 'dialog-thread-item');
a.href = t.kind === 'library' ? t.url : '/dialog/?thread=' + encodeURIComponent(t.key);
const tt = el('span', 'dialog-thread-title', t.title);
if (t.locked) tt.appendChild(el('span', 'dialog-lock', ' 🔒'));
const mt = el('span', 'dialog-thread-meta',
(t.author_name ? t.author_name + ' · ' : '') + t.count + (t.count === 1 ? ' Wortmeldung' : ' Wortmeldungen') + ' · ' + fmt(t.last));
a.append(tt, mt); list.appendChild(a);
});
root.appendChild(list);
});
}
function renderNewThread(box, slug) {
box.innerHTML = '';
if (!token) { const c = el('div'); box.appendChild(c); loginInline(c, () => renderNewThread(box, slug), 'Anmelden, um einen Thread zu starten'); return; }
let open = false;
function paint() {
box.innerHTML = '';
if (!open) {
const b = el('button', 'dialog-newbtn', ' Neuer Thread');
b.onclick = () => { open = true; paint(); };
box.appendChild(b);
return;
}
const ti = el('input', 'dialog-input'); ti.placeholder = 'Titel des Threads';
const ta = el('textarea', 'dialog-textarea'); ta.placeholder = 'Erster Beitrag …';
const row = el('div', 'dialog-row');
const send = el('button', 'dialog-send', 'Thread starten');
const cancel = el('button', 'dialog-loginlink', 'Abbrechen'); cancel.onclick = () => { open = false; paint(); };
send.onclick = async () => {
if (!ti.value.trim() || !ta.value.trim()) return;
const r = await api('/api/threads', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHdr() }, body: JSON.stringify({ forum_slug: slug, title: ti.value, body: ta.value }) });
if (r.status === 401) { logout(); alert('Sitzung abgelaufen — bitte neu anmelden.'); paint(); return; }
if (!r.ok) { alert(r.body.error || 'Konnte Thread nicht anlegen'); return; }
location.href = '/dialog/?thread=' + encodeURIComponent(r.body.key);
};
row.append(send, cancel); box.append(ti, ta, row); ti.focus();
}
paint();
}
// ── Thread-Ansicht: Wortmeldungen ─────────────────────────────────────────
function renderThread(key) {
root.innerHTML = '';
const title = el('h2', 'dialog-title', 'Dialog');
const modbar = el('div', 'dialog-modbar');
const list = el('div', 'dialog-list');
const composer = el('div', 'dialog-composer');
root.append(title, modbar, list, composer);
let replyTo = null, textarea = null, locked = false;
// Kontext: Rücklink + Titel.
api('/api/thread?key=' + encodeURIComponent(key)).then((r) => {
if (!r.ok) return;
const m = r.body; title.textContent = m.title || 'Dialog'; locked = m.locked;
if (ctxEl) {
ctxEl.innerHTML = '';
const back = el('a', null, m.kind === 'library' ? '← zum Beitrag' : (m.forum ? '← ' + m.forum.name : '← Dialoge'));
back.href = m.kind === 'library' ? m.url : (m.forum ? '/dialog/?forum=' + encodeURIComponent(m.forum.slug) : '/dialog/');
ctxEl.appendChild(back);
}
renderModbar(); renderComposer();
});
function renderModbar() {
modbar.innerHTML = '';
if (!canModerate()) return;
const lock = el('button', 'dialog-modbtn', locked ? 'Entsperren' : 'Sperren');
lock.onclick = async () => {
await api('/api/mod/thread-lock', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHdr() }, body: JSON.stringify({ key, locked: !locked }) });
locked = !locked; renderModbar(); renderComposer();
};
const del = el('button', 'dialog-modbtn', 'Thread ausblenden');
del.onclick = async () => {
if (!confirm('Thread ausblenden?')) return;
await api('/api/mod/thread-delete', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHdr() }, body: JSON.stringify({ key }) });
location.href = '/dialog/';
};
modbar.append(el('span', 'dialog-modlabel', 'Moderation:'), lock, del);
}
let lastSig = '';
async function load() {
const r = await api('/api/comments?thread=' + encodeURIComponent(key));
if (!r.ok) return;
const data = r.body;
const sig = (token ? 'in:' : 'out:') + (canModerate() ? 'm' : '') + data.map((c) => c.id + (c.deleted ? 'd' : '')).join(',');
if (sig !== lastSig) { lastSig = sig; render(data); }
}
function render(data) {
list.innerHTML = '';
const names = {}; data.forEach((c) => { names[c.id] = c.author_name; });
if (!data.length) { list.appendChild(el('p', 'dialog-empty', 'Noch keine Wortmeldungen — beginne den Dialog.')); return; }
data.forEach((c) => {
const card = el('article', 'dialog-card');
const head = el('header', 'dialog-card-head');
const av = el('span', 'dialog-avatar');
if (c.author_avatar) av.style.backgroundImage = 'url(' + c.author_avatar + ')';
else av.textContent = (c.author_name || '?').slice(0, 1).toUpperCase();
const meta = el('div', 'dialog-meta');
meta.append(el('span', 'dialog-name', c.author_name || 'Unbekannt'), el('time', 'dialog-time', fmt(c.created_at)));
if (c.parent_id && names[c.parent_id]) meta.appendChild(el('span', 'dialog-replyto', '↳ ' + names[c.parent_id]));
head.append(av, meta); card.appendChild(head);
card.appendChild(el('div', 'dialog-body', c.body));
if (token && !c.deleted) {
const actions = el('div', 'dialog-actions');
if (!locked) { const rep = el('button', null, 'Antworten'); rep.onclick = () => { replyTo = { id: c.id, name: c.author_name }; renderComposer(); if (textarea) textarea.focus(); }; actions.appendChild(rep); }
const del = el('button', null, 'Löschen'); del.onclick = () => remove(c.id); actions.appendChild(del);
card.appendChild(actions);
}
list.appendChild(card);
});
}
function renderComposer() {
composer.innerHTML = '';
// Gesperrt: niemand schreibt (auch Moderation nicht — erst entsperren).
if (locked) {
composer.appendChild(el('p', 'dialog-locked', canModerate()
? '🔒 Gesperrt — zum Schreiben oben entsperren.'
: '🔒 Dieser Thread ist gesperrt.'));
return;
}
if (!token) { loginInline(composer, () => { renderModbar(); renderComposer(); load(); }); return; }
if (replyTo) {
const r = el('button', 'dialog-replychip', 'Antwort auf ' + replyTo.name + ' ✕');
r.onclick = () => { replyTo = null; renderComposer(); };
composer.appendChild(r);
}
textarea = el('textarea', 'dialog-textarea'); textarea.placeholder = locked ? 'Thread gesperrt — nur Moderation …' : 'Deine Wortmeldung …';
const row = el('div', 'dialog-row');
const send = el('button', 'dialog-send', 'Senden'); send.onclick = submit;
const out = el('button', 'dialog-logout', 'Abmelden' + (myName ? ' · ' + myName : '')); out.onclick = () => logout(() => { renderModbar(); renderComposer(); load(); });
row.append(send, out); composer.append(textarea, row);
}
async function submit() {
const body = textarea.value.trim(); if (!body) return;
const r = await api('/api/comments', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHdr() }, body: JSON.stringify({ thread: key, body, parent_id: replyTo ? replyTo.id : null }) });
if (r.status === 401) { logout(); alert('Sitzung abgelaufen — bitte neu anmelden.'); renderComposer(); return; }
if (!r.ok) { alert(r.body.error || 'Senden fehlgeschlagen'); return; }
textarea.value = ''; replyTo = null; renderComposer(); load();
}
async function remove(id) {
if (!confirm('Wortmeldung löschen?')) return;
const r = await api('/api/comments/' + id, { method: 'DELETE', headers: authHdr() });
if (!r.ok) { alert(r.body.error || 'Löschen fehlgeschlagen'); return; }
load();
}
load();
setInterval(() => { if (!document.hidden) load(); }, 10000);
}
// ── Router ────────────────────────────────────────────────────────────────
if (threadKey) renderThread(threadKey);
else if (forumSlug) renderForum(forumSlug);
else renderOverview();
})();