diff --git a/assets/css/custom.css b/assets/css/custom.css index 7eaf292..f7377ca 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -424,6 +424,55 @@ a:hover { a.byline-author, a.journal-author { color: inherit; text-decoration: none; } a.byline-author:hover, a.journal-author:hover { color: var(--accent); } +/* ── Dialog (Diskussion pro Beitrag) ─────────────────────────────────────── */ +.dialog { + max-width: var(--container-width); + margin: var(--spacing-xl) auto; + padding: 0 var(--spacing-md); +} +.dialog-title { + font-family: var(--font-family-serif); + border-top: 1px solid var(--color-border); + padding-top: var(--spacing-md); + margin-bottom: var(--spacing-md); +} +.dialog-list { display: flex; flex-direction: column; gap: var(--spacing-md); margin-bottom: var(--spacing-lg); } +.dialog-empty { color: var(--color-text-muted); font-style: italic; } +.dialog-card { border: 1px solid var(--color-border); border-radius: 12px; padding: var(--spacing-md); background: var(--color-bg-secondary); } +.dialog-card-head { display: flex; align-items: center; gap: 0.7em; margin-bottom: 0.6em; } +.dialog-avatar { + width: 40px; height: 40px; border-radius: 50%; flex: none; + background: var(--color-border) center/cover no-repeat; + display: grid; place-items: center; font-weight: 600; color: var(--color-text-muted); +} +.dialog-meta { display: flex; flex-direction: column; line-height: 1.3; } +.dialog-name { font-weight: 600; } +.dialog-time { font-size: var(--font-size-small); color: var(--color-text-muted); } +.dialog-replyto { font-size: var(--font-size-small); color: var(--accent); } +.dialog-body { font-family: var(--font-family-serif); line-height: 1.6; white-space: pre-wrap; } +.dialog-actions { display: flex; gap: 0.8em; margin-top: 0.6em; } +.dialog-actions button { + background: none; border: none; padding: 0; cursor: pointer; + font-size: var(--font-size-small); color: var(--color-text-muted); +} +.dialog-actions button:hover { color: var(--accent); } + +.dialog-composer { display: flex; flex-direction: column; gap: 0.6em; } +.dialog-loginhint { color: var(--color-text-muted); margin: 0; } +.dialog-textarea, .dialog-input { + width: 100%; font: inherit; padding: 0.7em 0.9em; + border: 1px solid var(--color-border); border-radius: 10px; background: var(--color-bg-primary); +} +.dialog-textarea { min-height: 90px; resize: vertical; font-family: var(--font-family-serif); } +.dialog-row { display: flex; gap: 0.6em; } +.dialog-send { + font: inherit; cursor: pointer; padding: 0.55em 1.3em; border-radius: 999px; + background: var(--accent); color: #fff; border: 1px solid var(--accent); +} +.dialog-send:hover { background: #a23f23; } +.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; } + /* ------------------------------------------------------------------------ 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/cms/api/src/index.js b/cms/api/src/index.js index ef4d66e..2ed98e4 100644 --- a/cms/api/src/index.js +++ b/cms/api/src/index.js @@ -8,6 +8,7 @@ import publish from './routes/publish.js'; import upload from './routes/upload.js'; import profile from './routes/profile.js'; import users from './routes/users.js'; +import { listComments, createComment, deleteComment, login } from './routes/comments.js'; import { requireAuth } from './auth.js'; const SITE_DIR = process.env.SITE_DIR || '/site'; @@ -18,9 +19,14 @@ const app = new Hono(); // --- API --- app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' })); -// Alles unter /api/* (ausser /health oben) braucht ein gültiges Supabase-Token. +// Öffentlich (ohne Login): Dialog lesen + Login fürs Dialog-Widget. +app.get('/api/comments', listComments); +app.post('/api/auth/login', login); +// Alles weitere unter /api/* braucht ein gültiges Supabase-Token. app.use('/api/*', requireAuth); app.get('/api/me', (c) => c.json({ email: c.get('email'), isAdmin: c.get('isAdmin') })); +app.post('/api/comments', createComment); +app.delete('/api/comments/:id', deleteComment); app.route('/api/content', content); app.route('/api/preview', preview); app.route('/api/publish', publish); diff --git a/cms/api/src/routes/comments.js b/cms/api/src/routes/comments.js new file mode 100644 index 0000000..c0f4af7 --- /dev/null +++ b/cms/api/src/routes/comments.js @@ -0,0 +1,75 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { supabase } from '../supabase.js'; + +// Dialog: flache Wortmeldungen pro Thread (= Beitrags-Pfad), optionaler Bezug. +const SITE_DIR = process.env.SITE_DIR || '/site'; + +// Anzeigename + Avatar aus dem Profil (data/authors.json), Fallback = Mail-Teil. +async function profileFor(email) { + try { + const all = JSON.parse(await readFile(path.join(SITE_DIR, 'data', 'authors.json'), 'utf8')); + return all[email] || null; + } catch { return null; } +} + +const COLS = 'id,thread,parent_id,author_name,author_avatar,body,created_at,deleted'; + +// ÖFFENTLICH: Wortmeldungen eines Threads lesen. +export async function listComments(c) { + const thread = c.req.query('thread'); + if (!thread) return c.json({ error: 'thread fehlt' }, 400); + const { data, error } = await supabase + .from('comments').select(COLS).eq('thread', thread).order('created_at', { ascending: true }); + if (error) return c.json({ error: error.message }, 500); + const out = (data || []).map((r) => (r.deleted ? { ...r, body: '[gelöscht]', author_avatar: null } : r)); + return c.json(out); +} + +// EINGELOGGT: Wortmeldung schreiben. +export async function createComment(c) { + const user = c.get('user'); + const email = c.get('email'); + const { thread, body, parent_id } = await c.req.json(); + if (!thread || !body || !body.trim()) return c.json({ error: 'thread und Text nötig' }, 400); + + const prof = await profileFor(email); + const row = { + thread, + parent_id: parent_id || null, + user_id: user.id, + author_name: prof?.name || email.split('@')[0], + author_avatar: prof?.avatar || null, + body: body.trim(), + }; + const { data, error } = await supabase.from('comments').insert(row).select(COLS).single(); + if (error) return c.json({ error: error.message }, 400); + return c.json(data, 201); +} + +// EINGELOGGT: eigene Wortmeldung (oder als Admin jede) löschen. +export async function deleteComment(c) { + const user = c.get('user'); + const isAdmin = c.get('isAdmin'); + const id = c.req.param('id'); + const { data: row, error: e1 } = await supabase.from('comments').select('user_id').eq('id', id).single(); + if (e1 || !row) return c.json({ error: 'Nicht gefunden' }, 404); + if (!isAdmin && row.user_id !== user.id) return c.json({ error: 'Kein Recht' }, 403); + const { error } = await supabase.from('comments').update({ deleted: true }).eq('id', id); + if (error) return c.json({ error: error.message }, 400); + return c.json({ ok: true }); +} + +// ÖFFENTLICH: Login fürs Dialog-Widget — gibt das User-Token zurück. +export async function login(c) { + const { email, password } = await c.req.json(); + if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400); + const { data, error } = await supabase.auth.signInWithPassword({ email, password }); + if (error) return c.json({ error: error.message }, 401); + const prof = await profileFor((data.user.email || '').toLowerCase()); + return c.json({ + access_token: data.session.access_token, + email: data.user.email, + name: prof?.name || (data.user.email || '').split('@')[0], + }); +} diff --git a/cms/db/schema.sql b/cms/db/schema.sql index 8399d98..b65adb5 100644 --- a/cms/db/schema.sql +++ b/cms/db/schema.sql @@ -31,3 +31,21 @@ create index if not exists posts_section_idx on public.posts (section); -- RLS aktivieren; die api nutzt den Service-Key (umgeht RLS). Wenn das -- Frontend später direkt liest, hier gezielte Policies ergänzen. alter table public.posts enable row level security; + +-- ── Dialog / Diskussionen ─────────────────────────────────────────────── +-- Thread = Pfad des Beitrags (z.B. /library/software/stack/). Flache Wortmeldungen +-- mit optionalem Bezug (parent_id). Idempotent — auf bestehende DB anwendbar. +create table if not exists public.comments ( + id uuid primary key default gen_random_uuid(), + thread text not null, + parent_id uuid references public.comments(id) on delete cascade, + user_id uuid, + author_name text, + author_avatar text, + body text not null, + created_at timestamptz not null default now(), + deleted boolean not null default false +); +create index if not exists comments_thread_idx on public.comments (thread, created_at); +alter table public.comments enable row level security; +grant all on public.comments to anon, authenticated, service_role; diff --git a/layouts/_default/single.html b/layouts/_default/single.html index 6848c0e..37a88da 100644 --- a/layouts/_default/single.html +++ b/layouts/_default/single.html @@ -64,4 +64,8 @@ {{- end }} + {{/* Dialog — jeder Beitrag ist ein Diskussionsstart */}} +
+ + {{ end }} diff --git a/static/dialog.js b/static/dialog.js new file mode 100644 index 0000000..541806a --- /dev/null +++ b/static/dialog.js @@ -0,0 +1,140 @@ +/* OPENBUREAU Dialog — flache Wortmeldungen pro Beitrag. + Lesen öffentlich, Mitreden nach Login (eingeladene User). Kein Build nötig. */ +(function () { + const root = document.getElementById('ob-dialog'); + if (!root) return; + const thread = root.dataset.thread; + const TKEY = 'ob_dialog_token', NKEY = 'ob_dialog_name'; + let token = localStorage.getItem(TKEY); + let myName = localStorage.getItem(NKEY); + let replyTo = null; + let textarea = null; + + root.innerHTML = ''; + const title = document.createElement('h2'); + title.className = 'dialog-title'; title.textContent = 'Dialog'; + const list = document.createElement('div'); list.className = 'dialog-list'; + const composer = document.createElement('div'); composer.className = 'dialog-composer'; + root.append(title, list, composer); + + 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'); + } + + async function load() { + let data = []; + try { + const res = await fetch('/api/comments?thread=' + encodeURIComponent(thread)); + if (res.ok) data = await res.json(); + } catch { /* offline */ } + render(data); + } + + function render(data) { + list.innerHTML = ''; + const names = {}; data.forEach((c) => { names[c.id] = c.author_name; }); + if (!data.length) { + const e = document.createElement('p'); e.className = 'dialog-empty'; + e.textContent = 'Noch keine Wortmeldungen — beginne den Dialog.'; + list.appendChild(e); return; + } + data.forEach((c) => { + const card = document.createElement('article'); card.className = 'dialog-card'; + const head = document.createElement('header'); head.className = 'dialog-card-head'; + const av = document.createElement('span'); av.className = 'dialog-avatar'; + if (c.author_avatar) av.style.backgroundImage = 'url(' + c.author_avatar + ')'; + else av.textContent = (c.author_name || '?').slice(0, 1).toUpperCase(); + const meta = document.createElement('div'); meta.className = 'dialog-meta'; + const nm = document.createElement('span'); nm.className = 'dialog-name'; nm.textContent = c.author_name || 'Unbekannt'; + const tm = document.createElement('time'); tm.className = 'dialog-time'; tm.textContent = fmt(c.created_at); + meta.append(nm, tm); + if (c.parent_id && names[c.parent_id]) { + const rp = document.createElement('span'); rp.className = 'dialog-replyto'; rp.textContent = '↳ ' + names[c.parent_id]; + meta.appendChild(rp); + } + head.append(av, meta); card.appendChild(head); + const bd = document.createElement('div'); bd.className = 'dialog-body'; bd.textContent = c.body; card.appendChild(bd); + if (token && !c.deleted) { + const actions = document.createElement('div'); actions.className = 'dialog-actions'; + const rep = document.createElement('button'); rep.textContent = 'Antworten'; + rep.onclick = () => { replyTo = { id: c.id, name: c.author_name }; renderComposer(); if (textarea) textarea.focus(); }; + const del = document.createElement('button'); del.textContent = 'Löschen'; del.onclick = () => remove(c.id); + actions.append(rep, del); card.appendChild(actions); + } + list.appendChild(card); + }); + } + + function renderComposer() { + composer.innerHTML = ''; + if (token) { + if (replyTo) { + const r = document.createElement('button'); r.className = 'dialog-replychip'; + r.textContent = 'Antwort auf ' + replyTo.name + ' ✕'; + r.onclick = () => { replyTo = null; renderComposer(); }; + composer.appendChild(r); + } + textarea = document.createElement('textarea'); textarea.className = 'dialog-textarea'; + textarea.placeholder = 'Deine Wortmeldung …'; + const row = document.createElement('div'); row.className = 'dialog-row'; + const send = document.createElement('button'); send.className = 'dialog-send'; send.textContent = 'Senden'; send.onclick = submit; + const out = document.createElement('button'); out.className = 'dialog-logout'; out.textContent = 'Abmelden' + (myName ? ' · ' + myName : ''); out.onclick = logout; + row.append(send, out); composer.append(textarea, row); + } else { + const hint = document.createElement('p'); hint.className = 'dialog-loginhint'; hint.textContent = 'Zum Mitreden anmelden:'; + const em = document.createElement('input'); em.type = 'email'; em.placeholder = 'E-Mail'; em.className = 'dialog-input'; + const pw = document.createElement('input'); pw.type = 'password'; pw.placeholder = 'Passwort'; pw.className = 'dialog-input'; + const btn = document.createElement('button'); btn.className = 'dialog-send'; btn.textContent = 'Anmelden'; + btn.onclick = () => doLogin(em.value, pw.value); + pw.onkeydown = (e) => { if (e.key === 'Enter') doLogin(em.value, pw.value); }; + composer.append(hint, em, pw, btn); + } + } + + async function doLogin(email, password) { + try { + const res = await fetch('/api/auth/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const j = await res.json().catch(() => ({})); + if (!res.ok) { alert(j.error || 'Login fehlgeschlagen'); return; } + token = j.access_token; myName = j.name || ''; + localStorage.setItem(TKEY, token); localStorage.setItem(NKEY, myName); + renderComposer(); load(); + } catch { alert('Login fehlgeschlagen'); } + } + + function logout() { + token = null; myName = null; replyTo = null; + localStorage.removeItem(TKEY); localStorage.removeItem(NKEY); + renderComposer(); load(); + } + + async function submit() { + const body = textarea.value.trim(); if (!body) return; + const res = await fetch('/api/comments', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, + body: JSON.stringify({ thread, body, parent_id: replyTo ? replyTo.id : null }), + }); + if (res.status === 401) { logout(); alert('Sitzung abgelaufen — bitte neu anmelden.'); return; } + const j = await res.json().catch(() => ({})); + if (!res.ok) { alert(j.error || 'Senden fehlgeschlagen'); return; } + textarea.value = ''; replyTo = null; renderComposer(); load(); + } + + async function remove(id) { + if (!confirm('Wortmeldung löschen?')) return; + const res = await fetch('/api/comments/' + id, { method: 'DELETE', headers: { Authorization: 'Bearer ' + token } }); + if (!res.ok) { const j = await res.json().catch(() => ({})); alert(j.error || 'Löschen fehlgeschlagen'); return; } + load(); + } + + renderComposer(); + load(); +})();