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:
+264
-136
@@ -1,46 +1,26 @@
|
||||
/* OPENBUREAU Dialog — flache Wortmeldungen pro Beitrag.
|
||||
Lesen öffentlich, Mitreden nach Login (eingeladene User). Kein Build nötig. */
|
||||
/* 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 thread = root.dataset.thread || new URLSearchParams(location.search).get('thread') || '';
|
||||
if (!thread) { renderOverview(); return; }
|
||||
const ctxEl = document.getElementById('dialog-context');
|
||||
|
||||
// Übersicht aller begonnenen Dialoge (wenn kein Thema gewählt).
|
||||
function renderOverview() {
|
||||
root.innerHTML = '';
|
||||
const h = document.createElement('h2'); h.className = 'dialog-title'; h.textContent = 'Dialoge';
|
||||
const list = document.createElement('div'); list.className = 'dialog-overview';
|
||||
root.append(h, list);
|
||||
fetch('/api/threads').then((r) => (r.ok ? r.json() : [])).then((rows) => {
|
||||
if (!rows.length) {
|
||||
const e = document.createElement('p'); e.className = 'dialog-empty';
|
||||
e.textContent = 'Noch keine Dialoge begonnen.';
|
||||
list.appendChild(e); return;
|
||||
}
|
||||
rows.forEach((t) => {
|
||||
const a = document.createElement('a'); a.className = 'dialog-overview-item';
|
||||
a.href = '/dialog/?thread=' + encodeURIComponent(t.thread);
|
||||
const ti = document.createElement('span'); ti.className = 'dialog-ov-title'; ti.textContent = t.title;
|
||||
const mt = document.createElement('span'); mt.className = 'dialog-ov-meta';
|
||||
mt.textContent = t.count + (t.count === 1 ? ' Wortmeldung' : ' Wortmeldungen');
|
||||
a.append(ti, mt); list.appendChild(a);
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
const TKEY = 'ob_dialog_token', NKEY = 'ob_dialog_name';
|
||||
const TKEY = 'ob_dialog_token', NKEY = 'ob_dialog_name', RKEY = 'ob_dialog_role';
|
||||
let token = localStorage.getItem(TKEY);
|
||||
let myName = localStorage.getItem(NKEY);
|
||||
let replyTo = null;
|
||||
let textarea = null;
|
||||
let myRole = localStorage.getItem(RKEY) || 'user';
|
||||
const canModerate = () => token && (myRole === 'admin' || myRole === 'editor');
|
||||
|
||||
root.innerHTML = '';
|
||||
const title = document.createElement('h2');
|
||||
title.className = 'dialog-title'; title.textContent = 'Dialog';
|
||||
const list = document.createElement('div'); list.className = 'dialog-list';
|
||||
const composer = document.createElement('div'); composer.className = 'dialog-composer';
|
||||
root.append(title, list, composer);
|
||||
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';
|
||||
@@ -48,122 +28,270 @@
|
||||
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 });
|
||||
|
||||
let lastSig = '';
|
||||
async function load() {
|
||||
let data = [];
|
||||
try {
|
||||
const res = await fetch('/api/comments?thread=' + encodeURIComponent(thread));
|
||||
if (res.ok) data = await res.json();
|
||||
} catch { return; /* offline: alte Ansicht behalten */ }
|
||||
// Nur neu rendern, wenn sich wirklich etwas geändert hat (kein Flackern).
|
||||
const sig = (token ? 'in:' : 'out:') + data.map((c) => c.id + (c.deleted ? 'd' : '')).join(',');
|
||||
if (sig !== lastSig) { lastSig = sig; render(data); }
|
||||
// ── 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();
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
list.innerHTML = '';
|
||||
const names = {}; data.forEach((c) => { names[c.id] = c.author_name; });
|
||||
if (!data.length) {
|
||||
const e = document.createElement('p'); e.className = 'dialog-empty';
|
||||
e.textContent = 'Noch keine Wortmeldungen — beginne den Dialog.';
|
||||
list.appendChild(e); return;
|
||||
}
|
||||
data.forEach((c) => {
|
||||
const card = document.createElement('article'); card.className = 'dialog-card';
|
||||
const head = document.createElement('header'); head.className = 'dialog-card-head';
|
||||
const av = document.createElement('span'); av.className = '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 = document.createElement('div'); meta.className = 'dialog-meta';
|
||||
const nm = document.createElement('span'); nm.className = 'dialog-name'; nm.textContent = c.author_name || 'Unbekannt';
|
||||
const tm = document.createElement('time'); tm.className = 'dialog-time'; tm.textContent = fmt(c.created_at);
|
||||
meta.append(nm, tm);
|
||||
if (c.parent_id && names[c.parent_id]) {
|
||||
const rp = document.createElement('span'); rp.className = 'dialog-replyto'; rp.textContent = '↳ ' + names[c.parent_id];
|
||||
meta.appendChild(rp);
|
||||
}
|
||||
head.append(av, meta); card.appendChild(head);
|
||||
const bd = document.createElement('div'); bd.className = 'dialog-body'; bd.textContent = c.body; card.appendChild(bd);
|
||||
if (token && !c.deleted) {
|
||||
const actions = document.createElement('div'); actions.className = 'dialog-actions';
|
||||
const rep = document.createElement('button'); rep.textContent = 'Antworten';
|
||||
rep.onclick = () => { replyTo = { id: c.id, name: c.author_name }; renderComposer(); if (textarea) textarea.focus(); };
|
||||
const del = document.createElement('button'); del.textContent = 'Löschen'; del.onclick = () => remove(c.id);
|
||||
actions.append(rep, del); card.appendChild(actions);
|
||||
}
|
||||
list.appendChild(card);
|
||||
// ── Ü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);
|
||||
});
|
||||
}
|
||||
|
||||
function renderComposer() {
|
||||
composer.innerHTML = '';
|
||||
if (token) {
|
||||
// ── 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 = document.createElement('button'); r.className = 'dialog-replychip';
|
||||
r.textContent = 'Antwort auf ' + replyTo.name + ' ✕';
|
||||
const r = el('button', 'dialog-replychip', 'Antwort auf ' + replyTo.name + ' ✕');
|
||||
r.onclick = () => { replyTo = null; renderComposer(); };
|
||||
composer.appendChild(r);
|
||||
}
|
||||
textarea = document.createElement('textarea'); textarea.className = 'dialog-textarea';
|
||||
textarea.placeholder = 'Deine Wortmeldung …';
|
||||
const row = document.createElement('div'); row.className = 'dialog-row';
|
||||
const send = document.createElement('button'); send.className = 'dialog-send'; send.textContent = 'Senden'; send.onclick = submit;
|
||||
const out = document.createElement('button'); out.className = 'dialog-logout'; out.textContent = 'Abmelden' + (myName ? ' · ' + myName : ''); out.onclick = logout;
|
||||
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);
|
||||
} else {
|
||||
const hint = document.createElement('p'); hint.className = 'dialog-loginhint'; hint.textContent = 'Zum Mitreden anmelden:';
|
||||
const em = document.createElement('input'); em.type = 'email'; em.placeholder = 'E-Mail'; em.className = 'dialog-input';
|
||||
const pw = document.createElement('input'); pw.type = 'password'; pw.placeholder = 'Passwort'; pw.className = 'dialog-input';
|
||||
const btn = document.createElement('button'); btn.className = 'dialog-send'; btn.textContent = 'Anmelden';
|
||||
btn.onclick = () => doLogin(em.value, pw.value);
|
||||
pw.onkeydown = (e) => { if (e.key === 'Enter') doLogin(em.value, pw.value); };
|
||||
composer.append(hint, em, pw, btn);
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogin(email, password) {
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { alert(j.error || 'Login fehlgeschlagen'); return; }
|
||||
token = j.access_token; myName = j.name || '';
|
||||
localStorage.setItem(TKEY, token); localStorage.setItem(NKEY, myName);
|
||||
renderComposer(); load();
|
||||
} catch { alert('Login fehlgeschlagen'); }
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token = null; myName = null; replyTo = null;
|
||||
localStorage.removeItem(TKEY); localStorage.removeItem(NKEY);
|
||||
renderComposer(); load();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const body = textarea.value.trim(); if (!body) return;
|
||||
const res = await fetch('/api/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
|
||||
body: JSON.stringify({ thread, body, parent_id: replyTo ? replyTo.id : null }),
|
||||
});
|
||||
if (res.status === 401) { logout(); alert('Sitzung abgelaufen — bitte neu anmelden.'); return; }
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { alert(j.error || 'Senden fehlgeschlagen'); return; }
|
||||
textarea.value = ''; replyTo = null; renderComposer(); load();
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (!confirm('Wortmeldung löschen?')) return;
|
||||
const res = await fetch('/api/comments/' + id, { method: 'DELETE', headers: { Authorization: 'Bearer ' + token } });
|
||||
if (!res.ok) { const j = await res.json().catch(() => ({})); alert(j.error || 'Löschen fehlgeschlagen'); return; }
|
||||
load();
|
||||
setInterval(() => { if (!document.hidden) load(); }, 10000);
|
||||
}
|
||||
|
||||
renderComposer();
|
||||
load();
|
||||
// Live genug: alle 10 s nachladen (pausiert, wenn der Tab im Hintergrund ist).
|
||||
setInterval(() => { if (!document.hidden) load(); }, 10000);
|
||||
// ── Router ────────────────────────────────────────────────────────────────
|
||||
if (threadKey) renderThread(threadKey);
|
||||
else if (forumSlug) renderForum(forumSlug);
|
||||
else renderOverview();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user