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>
This commit is contained in:
2026-06-04 11:49:09 +02:00
parent 1fb4556ac1
commit 37fdc9019c
2 changed files with 69 additions and 4 deletions
+54 -4
View File
@@ -36,6 +36,45 @@
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 }) });
@@ -84,7 +123,11 @@
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');
@@ -101,6 +144,7 @@
});
api('/api/forums').then((r) => {
forumSkel.remove();
const rows = r.body || [];
const list = el('div', 'dialog-forum-list');
rows.forEach((f) => {
@@ -121,7 +165,9 @@
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');
@@ -176,6 +222,8 @@
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();
@@ -189,6 +237,7 @@
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.
@@ -249,8 +298,7 @@
const post = el('article', 'dialog-post');
const head = el('header', 'dialog-post-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();
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'));
@@ -258,7 +306,7 @@
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);
post.appendChild(el('div', 'dialog-body', c.body));
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); }
@@ -285,10 +333,12 @@
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, out); composer.append(textarea, row);
row.append(send, hint, el('span', 'dialog-spacer'), out); composer.append(textarea, row);
}
async function submit() {