/* 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= → ein Forum mit seinen Threads /dialog/?thread= → 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(); })();