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
+15
View File
@@ -704,6 +704,21 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
.dialog-logout { font: inherit; cursor: pointer; padding: 0.55em 1.1em; border-radius: 999px; background: none; border: 1px solid var(--color-border); color: var(--color-text-muted); } .dialog-logout { font: inherit; cursor: pointer; padding: 0.55em 1.1em; border-radius: 999px; background: none; border: 1px solid var(--color-border); color: var(--color-text-muted); }
.dialog-replychip { align-self: flex-start; font-size: var(--font-size-small); cursor: pointer; padding: 0.25em 0.8em; border-radius: 999px; border: 1px solid var(--accent); color: var(--accent); background: none; } .dialog-replychip { align-self: flex-start; font-size: var(--font-size-small); cursor: pointer; padding: 0.25em 0.8em; border-radius: 999px; border: 1px solid var(--accent); color: var(--accent); background: none; }
/* ── Dialog: Lade-Skelett, Links im Text, Composer-Hinweis ── */
.dialog-skel { display: flex; flex-direction: column; gap: 1.1em; padding: 0.7em 0; }
.dialog-skel-line {
height: 1.05em; width: 100%; border-radius: 6px;
background: linear-gradient(90deg, var(--color-border) 25%, var(--color-bg-secondary) 37%, var(--color-border) 63%);
background-size: 400% 100%; animation: dialog-shimmer 1.4s ease infinite;
}
.dialog-skel-line:nth-child(3n) { width: 68%; }
.dialog-skel-line:nth-child(3n+1) { width: 92%; }
@keyframes dialog-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
@media (prefers-reduced-motion: reduce) { .dialog-skel-line { animation: none; } }
.dialog-body .dialog-link { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; word-break: break-word; }
.dialog-hint { font-size: var(--font-size-small); color: var(--color-text-muted); align-self: center; opacity: 0.7; }
.dialog-spacer { flex: 1; }
/* ------------------------------------------------------------------------ /* ------------------------------------------------------------------------
Journal entries — three Republik-style layouts (set in front matter Journal entries — three Republik-style layouts (set in front matter
via `layout: image|icon|text`). Every entry is a full-bleed coloured via `layout: image|icon|text`). Every entry is a full-bleed coloured
+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 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 }); 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 ──────────────────────────────────────────────────────────────── // ── Auth ────────────────────────────────────────────────────────────────
async function doLogin(email, password, after) { 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 }) }); 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')); right.appendChild(el('h2', 'dialog-title', 'Foren'));
grid.append(left, right); root.appendChild(grid); 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) => { api('/api/recent?limit=15').then((r) => {
recentSkel.remove();
const rows = r.body || []; const rows = r.body || [];
if (!rows.length) { left.appendChild(el('p', 'dialog-empty', 'Noch keine Wortmeldungen.')); return; } if (!rows.length) { left.appendChild(el('p', 'dialog-empty', 'Noch keine Wortmeldungen.')); return; }
const list = el('div', 'dialog-recent-list'); const list = el('div', 'dialog-recent-list');
@@ -101,6 +144,7 @@
}); });
api('/api/forums').then((r) => { api('/api/forums').then((r) => {
forumSkel.remove();
const rows = r.body || []; const rows = r.body || [];
const list = el('div', 'dialog-forum-list'); const list = el('div', 'dialog-forum-list');
rows.forEach((f) => { rows.forEach((f) => {
@@ -121,7 +165,9 @@
function renderForum(slug) { function renderForum(slug) {
root.innerHTML = ''; 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); } 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) => { api('/api/forums/' + encodeURIComponent(slug)).then((r) => {
loadHost.remove();
if (!r.ok) { root.appendChild(el('p', 'dialog-empty', 'Forum nicht gefunden.')); return; } if (!r.ok) { root.appendChild(el('p', 'dialog-empty', 'Forum nicht gefunden.')); return; }
const { forum, threads } = r.body; const { forum, threads } = r.body;
const head = el('div', 'dialog-forum-head'); const head = el('div', 'dialog-forum-head');
@@ -176,6 +222,8 @@
if (!r.ok) { alert(r.body.error || 'Konnte Thread nicht anlegen'); return; } if (!r.ok) { alert(r.body.error || 'Konnte Thread nicht anlegen'); return; }
location.href = '/dialog/?thread=' + encodeURIComponent(r.body.key); 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(); row.append(send, cancel); box.append(ti, ta, row); ti.focus();
} }
paint(); paint();
@@ -189,6 +237,7 @@
const list = el('div', 'dialog-list'); const list = el('div', 'dialog-list');
const composer = el('div', 'dialog-composer'); const composer = el('div', 'dialog-composer');
root.append(title, modbar, list, composer); root.append(title, modbar, list, composer);
skeleton(list, 3);
let replyTo = null, textarea = null, locked = false; let replyTo = null, textarea = null, locked = false;
// Kontext: Rücklink + Titel. // Kontext: Rücklink + Titel.
@@ -249,8 +298,7 @@
const post = el('article', 'dialog-post'); const post = el('article', 'dialog-post');
const head = el('header', 'dialog-post-head'); const head = el('header', 'dialog-post-head');
const av = el('span', 'dialog-avatar'); const av = el('span', 'dialog-avatar');
if (c.author_avatar) av.style.backgroundImage = 'url(' + c.author_avatar + ')'; paintAvatar(av, c.author_name, c.author_avatar);
else av.textContent = (c.author_name || '?').slice(0, 1).toUpperCase();
const ident = el('div', 'dialog-ident'); const ident = el('div', 'dialog-ident');
const nameline = el('div', 'dialog-nameline'); const nameline = el('div', 'dialog-nameline');
nameline.appendChild(el('span', 'dialog-name', c.author_name || 'Unbekannt')); 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])); 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))); ident.append(nameline, el('time', 'dialog-time', fmtFull(c.created_at)));
head.append(av, ident); post.appendChild(head); 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) { if (token && !c.deleted) {
const actions = el('div', 'dialog-actions'); 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); } 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); composer.appendChild(r);
} }
textarea = el('textarea', 'dialog-textarea'); textarea.placeholder = locked ? 'Thread gesperrt — nur Moderation …' : 'Deine Wortmeldung …'; 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 row = el('div', 'dialog-row');
const send = el('button', 'dialog-send', 'Senden'); send.onclick = submit; 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(); }); 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() { async function submit() {