diff --git a/assets/css/custom.css b/assets/css/custom.css index 0a6114a..e5c0944 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -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-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 via `layout: image|icon|text`). Every entry is a full-bleed coloured diff --git a/static/dialog.js b/static/dialog.js index fd3c385..eed7cde 100644 --- a/static/dialog.js +++ b/static/dialog.js @@ -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() {