dialog: eigene Diskussion pro Beitrag (MVP, flach, eingeladene-only)
- DB: public.comments (thread, parent_id, user_id, author_name/-avatar, body, deleted) - API: GET /api/comments (öffentlich lesen), POST/DELETE (eingeloggt), POST /api/auth/login (Token fürs Widget) - Vanilla-Widget static/dialog.js: Karten mit Name+Bild, flacher Dialog mit optionalem Bezug (↳ Antwort auf), Inline-Login, Löschen (eigene/Admin) - eingebettet in single.html (thread = Beitrags-Pfad), Styling im Theme-Look - Autorname/-bild kommen aus dem Profil (data/authors.json) Realtime (Supabase) folgt als nächster Schritt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -424,6 +424,55 @@ a:hover {
|
|||||||
a.byline-author, a.journal-author { color: inherit; text-decoration: none; }
|
a.byline-author, a.journal-author { color: inherit; text-decoration: none; }
|
||||||
a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
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
|
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
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import publish from './routes/publish.js';
|
|||||||
import upload from './routes/upload.js';
|
import upload from './routes/upload.js';
|
||||||
import profile from './routes/profile.js';
|
import profile from './routes/profile.js';
|
||||||
import users from './routes/users.js';
|
import users from './routes/users.js';
|
||||||
|
import { listComments, createComment, deleteComment, login } from './routes/comments.js';
|
||||||
import { requireAuth } from './auth.js';
|
import { requireAuth } from './auth.js';
|
||||||
|
|
||||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||||
@@ -18,9 +19,14 @@ const app = new Hono();
|
|||||||
|
|
||||||
// --- API ---
|
// --- API ---
|
||||||
app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' }));
|
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.use('/api/*', requireAuth);
|
||||||
app.get('/api/me', (c) => c.json({ email: c.get('email'), isAdmin: c.get('isAdmin') }));
|
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/content', content);
|
||||||
app.route('/api/preview', preview);
|
app.route('/api/preview', preview);
|
||||||
app.route('/api/publish', publish);
|
app.route('/api/publish', publish);
|
||||||
|
|||||||
@@ -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],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
-- RLS aktivieren; die api nutzt den Service-Key (umgeht RLS). Wenn das
|
||||||
-- Frontend später direkt liest, hier gezielte Policies ergänzen.
|
-- Frontend später direkt liest, hier gezielte Policies ergänzen.
|
||||||
alter table public.posts enable row level security;
|
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;
|
||||||
|
|||||||
@@ -64,4 +64,8 @@
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
{{/* Dialog — jeder Beitrag ist ein Diskussionsstart */}}
|
||||||
|
<section class="dialog" id="ob-dialog" data-thread="{{ .RelPermalink }}"></section>
|
||||||
|
<script defer src="/dialog.js"></script>
|
||||||
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user