Files
OPENBUREAU/static/dialog.js
T
karim 37fdc9019c dialog: UX-Pass — Lade-Skelette, Avatar-Farben, Links, Cmd+Enter
- Dezente Lade-Skelette (Shimmer) in Übersicht/Forum/Thread statt leerem Pop-in
- Deterministische, ruhige Avatar-Farbe aus dem Namen (statt einheitlichem Grau)
- URLs in Wortmeldungen klickbar (sicher, ohne innerHTML)
- ⌘/Ctrl+Enter sendet; Textarea wächst mit dem Inhalt; Hinweis im Composer
- Enter im Thread-Titel springt ins Textfeld
- prefers-reduced-motion respektiert

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:49:09 +02:00

367 lines
19 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');
}
// Volles Datum + Uhrzeit (für die Wortmeldungen in der Thread-Ansicht).
function fmtFull(ts) {
const d = new Date(ts);
return d.toLocaleDateString('de-CH') + ' · ' + d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' });
}
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 });
// ── UX-Helfer ─────────────────────────────────────────────────────────────
// Deterministische, dezente Avatar-Farbe aus dem Namen (wenn kein Bild).
function hashHue(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 paintAvatar(av, name, avatarUrl) {
if (avatarUrl) { av.style.backgroundImage = 'url(' + avatarUrl + ')'; av.textContent = ''; return; }
const hue = hashHue(name || '?');
av.style.background = 'hsl(' + hue + ' 36% 82%)';
av.style.color = 'hsl(' + hue + ' 30% 28%)';
av.textContent = (name || '?').trim().slice(0, 1).toUpperCase();
}
// URLs im Text klickbar machen — sicher: nur Text- + Anchor-Knoten, kein innerHTML.
function linkify(container, text) {
const re = /(https?:\/\/[^\s<]+)/g; let last = 0, m;
while ((m = re.exec(text))) {
if (m.index > last) container.appendChild(document.createTextNode(text.slice(last, m.index)));
const a = el('a', 'dialog-link', m[0].replace(/[.,;:)]+$/, ''));
a.href = a.textContent; a.target = '_blank'; a.rel = 'noopener noreferrer';
container.appendChild(a);
last = m.index + a.textContent.length;
}
if (last < text.length) container.appendChild(document.createTextNode(text.slice(last)));
}
// Dezente Lade-Platzhalter (schimmernd).
function skeleton(container, n) {
container.innerHTML = '';
const w = el('div', 'dialog-skel');
for (let i = 0; i < (n || 3); i++) w.appendChild(el('div', 'dialog-skel-line'));
container.appendChild(w);
}
// ⌘/Ctrl + Enter sendet ab.
function sendOnCmdEnter(ta, fn) {
ta.addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); fn(); } });
}
// Textarea wächst mit dem Inhalt mit (bis zu einer Grenze).
function autoGrow(ta, max) {
const fit = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, max || 320) + 'px'; };
ta.addEventListener('input', fit); requestAnimationFrame(fit);
}
// ── 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);
const recentSkel = el('div'); left.appendChild(recentSkel); skeleton(recentSkel, 5);
const forumSkel = el('div'); right.appendChild(forumSkel); skeleton(forumSkel, 4);
api('/api/recent?limit=15').then((r) => {
recentSkel.remove();
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) => {
forumSkel.remove();
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 c = el('nav', 'dialog-crumb'); const b = el('a', null, '← Dialoge'); b.href = '/dialog/'; c.appendChild(b); ctxEl.appendChild(c); }
const loadHost = el('div'); skeleton(loadHost, 4); root.appendChild(loadHost);
api('/api/forums/' + encodeURIComponent(slug)).then((r) => {
loadHost.remove();
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);
};
autoGrow(ta); sendOnCmdEnter(ta, () => send.click());
ti.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); ta.focus(); } });
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);
skeleton(list, 3);
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 = '';
if (m.kind === 'library') {
const back = el('a', null, '← zum Beitrag'); back.href = m.url; ctxEl.appendChild(back);
} else {
// Breadcrumb-Navigation oben: Dialoge Forum (beide anklickbar).
const crumb = el('nav', 'dialog-crumb');
const a0 = el('a', null, 'Dialoge'); a0.href = '/dialog/'; crumb.appendChild(a0);
if (m.forum) {
crumb.appendChild(el('span', 'dialog-crumb-sep', ''));
const a1 = el('a', null, m.forum.name); a1.href = '/dialog/?forum=' + encodeURIComponent(m.forum.slug);
crumb.appendChild(a1);
}
ctxEl.appendChild(crumb);
}
}
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) => {
// Nüchtern: Avatar · Name (+ Position) · darunter Datum/Uhrzeit · Text.
const post = el('article', 'dialog-post');
const head = el('header', 'dialog-post-head');
const av = el('span', 'dialog-avatar');
paintAvatar(av, c.author_name, c.author_avatar);
const ident = el('div', 'dialog-ident');
const nameline = el('div', 'dialog-nameline');
nameline.appendChild(el('span', 'dialog-name', c.author_name || 'Unbekannt'));
if (c.author_role) nameline.appendChild(el('span', 'dialog-pos', c.author_role));
if (c.parent_id && names[c.parent_id]) nameline.appendChild(el('span', 'dialog-replyto', '↳ ' + names[c.parent_id]));
ident.append(nameline, el('time', 'dialog-time', fmtFull(c.created_at)));
head.append(av, ident); post.appendChild(head);
const bodyEl = el('div', 'dialog-body'); linkify(bodyEl, c.body || ''); post.appendChild(bodyEl);
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);
post.appendChild(actions);
}
list.appendChild(post);
});
}
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 …';
autoGrow(textarea); sendOnCmdEnter(textarea, submit);
const row = el('div', 'dialog-row');
const send = el('button', 'dialog-send', 'Senden'); send.onclick = submit;
const hint = el('span', 'dialog-hint', '⌘ + ↵');
const out = el('button', 'dialog-logout', 'Abmelden' + (myName ? ' · ' + myName : '')); out.onclick = () => logout(() => { renderModbar(); renderComposer(); load(); });
row.append(send, hint, el('span', 'dialog-spacer'), 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();
})();