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:
@@ -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
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user