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:
2026-05-31 14:02:59 +02:00
parent 132503fc8b
commit e787961059
6 changed files with 293 additions and 1 deletions
+7 -1
View File
@@ -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);
+75
View File
@@ -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],
});
}
+18
View File
@@ -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;