dialog: Diskussionsplattform mit Foren, Rollen & Moderation + RLS-Fix
Auth/RLS-Fix (Schreiben gab 400): - supabase.js: eigener supabaseAuth-Client für Login/Token-Check, damit signInWithPassword den Service-Daten-Client nicht prozessweit aufs User-Token umstellt (sonst lief insert als role=authenticated → RLS-Block). Rollen (admin > editor > user): - auth.js: roleOf() aus app_metadata.role + ADMIN_EMAILS, requireModerator. - users.js: Rolle anzeigen/setzen über GoTrue app_metadata; .env-Admins fix. Datenmodell (schema.sql): - forums (Kategorien) + threads; Seed Allgemein/Projekte/Technik/Off-Topic und Sonder-Kategorie Beiträge. Library-Beiträge werden als Threads gespiegelt (dialog-store.syncLibrary). API (routes/dialog.js, dialog-store.js): - öffentlich: /api/forums, /api/forums/:slug, /api/recent, /api/thread - eingeloggt: POST /api/threads (Thread starten, nur in Foren) - Moderation: /api/mod/* (sperren/ausblenden), Admin: /api/admin/forums CRUD - comments: Lock-Prüfung beim Schreiben, Moderation darf jede löschen. Frontend: - static/dialog.js: Router (Übersicht-Split-View | Forum | Thread), neuer Thread, Mod-Leiste, subtiles Login (dezente Zeile statt Formular). - Admin-UI: Tabs Foren + Moderation, Rollen-Dropdown bei Autor:innen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -510,6 +510,57 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
|||||||
|
|
||||||
.dialog-composer { display: flex; flex-direction: column; gap: 0.6em; }
|
.dialog-composer { display: flex; flex-direction: column; gap: 0.6em; }
|
||||||
.dialog-loginhint { color: var(--color-text-muted); margin: 0; }
|
.dialog-loginhint { color: var(--color-text-muted); margin: 0; }
|
||||||
|
/* Subtiles Login: dezenter Text-Link statt dominantem Formular. */
|
||||||
|
.dialog-loginlink {
|
||||||
|
align-self: flex-start; font: inherit; font-size: var(--font-size-small);
|
||||||
|
background: none; border: none; padding: 0.2em 0; cursor: pointer;
|
||||||
|
color: var(--color-text-muted); text-decoration: underline; text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
.dialog-loginlink:hover { color: var(--accent); }
|
||||||
|
.dialog-loginform { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em; }
|
||||||
|
.dialog-loginform .dialog-input { width: auto; flex: 1 1 9em; min-width: 8em; padding: 0.45em 0.7em; }
|
||||||
|
.dialog-loginform .dialog-send { padding: 0.45em 1em; }
|
||||||
|
.dialog-logincancel { align-self: center; }
|
||||||
|
|
||||||
|
/* ── Übersicht: Split-View (links letzte Wortmeldungen, rechts Foren) ── */
|
||||||
|
.dialog-split { display: grid; grid-template-columns: 1.2fr 1fr; gap: 2.4em; align-items: start; }
|
||||||
|
@media (max-width: 720px) { .dialog-split { grid-template-columns: 1fr; gap: 1.6em; } }
|
||||||
|
.dialog-recent-list, .dialog-forum-list, .dialog-thread-list { display: flex; flex-direction: column; gap: 0.7em; }
|
||||||
|
.dialog-recent-item, .dialog-forum-item, .dialog-thread-item {
|
||||||
|
display: block; text-decoration: none; color: inherit;
|
||||||
|
border: 1px solid var(--color-border); border-radius: 12px; padding: 0.85em 1em; background: var(--color-bg-primary);
|
||||||
|
transition: border-color .12s, transform .12s;
|
||||||
|
}
|
||||||
|
.dialog-recent-item:hover, .dialog-forum-item:hover, .dialog-thread-item:hover { border-color: var(--accent); }
|
||||||
|
.dialog-recent-top { display: flex; justify-content: space-between; gap: 1em; align-items: baseline; }
|
||||||
|
.dialog-recent-author { font-weight: 600; }
|
||||||
|
.dialog-recent-meta, .dialog-thread-meta, .dialog-forum-meta { color: var(--color-text-muted); font-size: var(--font-size-small); }
|
||||||
|
.dialog-recent-thread { font-family: var(--font-family-serif); font-style: italic; color: var(--accent); margin: 0.15em 0; }
|
||||||
|
.dialog-recent-body { color: var(--color-text-muted); font-size: var(--font-size-small);
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
|
||||||
|
/* Foren-Karten — linke Akzentkante aus der Forum-Farbe */
|
||||||
|
.dialog-forum-item { border-left: 3px solid var(--forum-accent, var(--accent)); }
|
||||||
|
.dialog-forum-name { display: block; font-weight: 600; font-size: 1.05em; }
|
||||||
|
.dialog-forum-desc { display: block; color: var(--color-text-muted); font-size: var(--font-size-small); margin-top: 0.2em; }
|
||||||
|
|
||||||
|
/* Forum-Ansicht */
|
||||||
|
.dialog-forum-head { border-left: 4px solid var(--forum-accent, var(--accent)); padding-left: 0.6em; }
|
||||||
|
.dialog-thread-title { display: block; font-weight: 600; }
|
||||||
|
.dialog-lock { color: var(--color-text-muted); }
|
||||||
|
.dialog-newthread { margin: 0.4em 0 1.4em; display: flex; flex-direction: column; gap: 0.6em; }
|
||||||
|
.dialog-newbtn { align-self: flex-start; font: inherit; cursor: pointer; padding: 0.5em 1.2em; border-radius: 999px;
|
||||||
|
background: var(--accent); color: #fff; border: 1px solid var(--accent); }
|
||||||
|
.dialog-newbtn:hover { background: #a23f23; }
|
||||||
|
|
||||||
|
/* Thread-Ansicht: Moderationsleiste */
|
||||||
|
.dialog-modbar { display: flex; align-items: center; gap: 0.6em; margin: 0.2em 0 1em; flex-wrap: wrap; }
|
||||||
|
.dialog-modbar:empty { display: none; }
|
||||||
|
.dialog-modlabel { color: var(--color-text-muted); font-size: var(--font-size-small); }
|
||||||
|
.dialog-modbtn { font: inherit; font-size: var(--font-size-small); cursor: pointer; padding: 0.3em 0.9em; border-radius: 999px;
|
||||||
|
background: none; border: 1px solid var(--color-border); color: var(--color-text-muted); }
|
||||||
|
.dialog-modbtn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.dialog-locked { color: var(--color-text-muted); font-style: italic; margin: 0.4em 0; }
|
||||||
.dialog-textarea, .dialog-input {
|
.dialog-textarea, .dialog-input {
|
||||||
width: 100%; font: inherit; padding: 0.7em 0.9em;
|
width: 100%; font: inherit; padding: 0.7em 0.9em;
|
||||||
border: 1px solid var(--color-border); border-radius: 10px; background: var(--color-bg-primary);
|
border: 1px solid var(--color-border); border-radius: 10px; background: var(--color-bg-primary);
|
||||||
|
|||||||
+156
-4
@@ -100,6 +100,8 @@ function Dashboard({ email }) {
|
|||||||
<nav className="nav">
|
<nav className="nav">
|
||||||
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
|
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
|
||||||
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
|
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
|
||||||
|
{me?.canModerate && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
|
||||||
|
{me?.isAdmin && <button className={view === 'forums' ? 'active' : ''} onClick={() => setView('forums')}>Foren</button>}
|
||||||
{me?.isAdmin && <button className={view === 'users' ? 'active' : ''} onClick={() => setView('users')}>Autor:innen</button>}
|
{me?.isAdmin && <button className={view === 'users' ? 'active' : ''} onClick={() => setView('users')}>Autor:innen</button>}
|
||||||
</nav>
|
</nav>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
@@ -112,6 +114,10 @@ function Dashboard({ email }) {
|
|||||||
<Profile onMsg={setMsg} />
|
<Profile onMsg={setMsg} />
|
||||||
) : view === 'users' ? (
|
) : view === 'users' ? (
|
||||||
<Users onMsg={setMsg} currentEmail={me?.email} />
|
<Users onMsg={setMsg} currentEmail={me?.email} />
|
||||||
|
) : view === 'forums' ? (
|
||||||
|
<Forums onMsg={setMsg} />
|
||||||
|
) : view === 'moderation' ? (
|
||||||
|
<Moderation onMsg={setMsg} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<aside>
|
<aside>
|
||||||
@@ -422,12 +428,16 @@ function Users({ onMsg, currentEmail }) {
|
|||||||
try { await api.setPassword(u.id, pw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); }
|
try { await api.setPassword(u.id, pw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); }
|
||||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
}
|
}
|
||||||
|
async function changeRole(u, role) {
|
||||||
|
try { await api.setRole(u.id, role); onMsg({ type: 'ok', text: `Rolle: ${ROLE_LABEL[role]}` }); refresh(); }
|
||||||
|
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
if (!list) return <div className="empty">…</div>;
|
if (!list) return <div className="empty">…</div>;
|
||||||
return (
|
return (
|
||||||
<div className="profile">
|
<div className="profile">
|
||||||
<div className="profile-card">
|
<div className="profile-card">
|
||||||
<h2>Autor:innen</h2>
|
<h2>Autor:innen & Rollen</h2>
|
||||||
<form className="userform" onSubmit={create}>
|
<form className="userform" onSubmit={create}>
|
||||||
<input type="email" placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
<input type="email" placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||||
<input type="text" placeholder="Passwort" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
<input type="text" placeholder="Passwort" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||||
@@ -436,18 +446,160 @@ function Users({ onMsg, currentEmail }) {
|
|||||||
<ul className="userlist">
|
<ul className="userlist">
|
||||||
{list.map((u) => (
|
{list.map((u) => (
|
||||||
<li key={u.id}>
|
<li key={u.id}>
|
||||||
<span className="t">{u.email}{u.isAdmin && <span className="status live">Admin</span>}</span>
|
<span className="t">{u.email}</span>
|
||||||
|
{u.fixedAdmin
|
||||||
|
? <span className="status live">Admin (.env)</span>
|
||||||
|
: <select className="role-select" value={u.role} onChange={(e) => changeRole(u, e.target.value)}>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="editor">Redakteur</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>}
|
||||||
<button onClick={() => reset(u)}>Passwort</button>
|
<button onClick={() => reset(u)}>Passwort</button>
|
||||||
{u.email !== currentEmail && <button onClick={() => remove(u)}>Löschen</button>}
|
{u.email !== currentEmail && !u.fixedAdmin && <button onClick={() => remove(u)}>Löschen</button>}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<p className="muted who-mail">Admin-Rechte werden über <code>ADMIN_EMAILS</code> in der .env vergeben (nicht hier).</p>
|
<p className="muted who-mail"><b>User</b> schreiben nur im Forum · <b>Redakteur</b> moderiert · <b>Admin</b> verwaltet alles. Admins aus <code>ADMIN_EMAILS</code> sind fix.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROLE_LABEL = { user: 'User', editor: 'Redakteur', admin: 'Admin' };
|
||||||
|
|
||||||
|
// ── Foren-Verwaltung (nur Admin) ────────────────────────────────────────────
|
||||||
|
function Forums({ onMsg }) {
|
||||||
|
const [list, setList] = useState(null);
|
||||||
|
const [draft, setDraft] = useState({ slug: '', name: '', sort: 50 });
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try { setList(await api.listForumsAdmin()); }
|
||||||
|
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
|
}
|
||||||
|
useEffect(() => { refresh(); }, []);
|
||||||
|
|
||||||
|
async function create(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!draft.slug || !draft.name) return;
|
||||||
|
setBusy(true);
|
||||||
|
try { await api.createForum(draft); onMsg({ type: 'ok', text: 'Kategorie angelegt.' }); setDraft({ slug: '', name: '', sort: 50 }); refresh(); }
|
||||||
|
catch (err) { onMsg({ type: 'err', text: err.message }); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function save(f, patch) {
|
||||||
|
try { await api.updateForum(f.id, patch); refresh(); }
|
||||||
|
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
|
}
|
||||||
|
async function remove(f) {
|
||||||
|
if (!confirm(`Kategorie „${f.name}“ löschen? Threads darin verschwinden.`)) return;
|
||||||
|
try { await api.deleteForum(f.id); onMsg({ type: 'ok', text: 'Gelöscht.' }); refresh(); }
|
||||||
|
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!list) return <div className="empty">…</div>;
|
||||||
|
return (
|
||||||
|
<div className="profile">
|
||||||
|
<div className="profile-card wide">
|
||||||
|
<h2>Foren / Kategorien</h2>
|
||||||
|
<form className="userform" onSubmit={create}>
|
||||||
|
<input placeholder="Name (z. B. Wettbewerbe)" value={draft.name}
|
||||||
|
onChange={(e) => setDraft({ ...draft, name: e.target.value, slug: draft.slug || slugify(e.target.value) })} required />
|
||||||
|
<input placeholder="slug" value={draft.slug} onChange={(e) => setDraft({ ...draft, slug: slugify(e.target.value) })} required />
|
||||||
|
<input type="number" placeholder="Sort" style={{ width: '5em' }} value={draft.sort} onChange={(e) => setDraft({ ...draft, sort: e.target.value })} />
|
||||||
|
<button className="primary" disabled={busy}>Anlegen</button>
|
||||||
|
</form>
|
||||||
|
<ul className="forumlist">
|
||||||
|
{list.map((f) => (
|
||||||
|
<li key={f.id} className={f.kind === 'library' ? 'is-library' : ''}>
|
||||||
|
<span className="fsort">{f.sort}</span>
|
||||||
|
<input className="fname" defaultValue={f.name} onBlur={(e) => e.target.value !== f.name && save(f, { name: e.target.value })} />
|
||||||
|
<input className="fcolor" type="color" value={/^#[0-9a-fA-F]{6}$/.test(f.color || '') ? f.color : '#cccccc'} onChange={(e) => save(f, { color: e.target.value })} title="Akzentfarbe" />
|
||||||
|
<span className="fslug">/{f.slug}</span>
|
||||||
|
{f.kind === 'library'
|
||||||
|
? <span className="status">Library (auto)</span>
|
||||||
|
: <button onClick={() => remove(f)}>Löschen</button>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="muted who-mail">„Beiträge“ ist die automatische Library-Kategorie und kann nicht gelöscht werden.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Moderation (Admin + Redakteur) ──────────────────────────────────────────
|
||||||
|
function Moderation({ onMsg }) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try { setData(await api.modOverview()); }
|
||||||
|
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
|
}
|
||||||
|
useEffect(() => { refresh(); }, []);
|
||||||
|
|
||||||
|
async function delComment(c) {
|
||||||
|
if (!confirm('Wortmeldung löschen?')) return;
|
||||||
|
try { await api.deleteComment(c.id); onMsg({ type: 'ok', text: 'Gelöscht.' }); refresh(); }
|
||||||
|
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
|
}
|
||||||
|
async function toggleLock(t) {
|
||||||
|
try { await api.lockThread(t.key, !t.locked); refresh(); }
|
||||||
|
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
|
}
|
||||||
|
async function delThread(t) {
|
||||||
|
if (!confirm(`Thread „${t.title}“ ausblenden?`)) return;
|
||||||
|
try { await api.deleteThread(t.key); onMsg({ type: 'ok', text: 'Ausgeblendet.' }); refresh(); }
|
||||||
|
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return <div className="empty">…</div>;
|
||||||
|
return (
|
||||||
|
<div className="moderation">
|
||||||
|
<div className="mod-col">
|
||||||
|
<h2>Letzte Wortmeldungen</h2>
|
||||||
|
<ul className="modlist">
|
||||||
|
{data.comments.map((c) => (
|
||||||
|
<li key={c.id}>
|
||||||
|
<div className="mod-head"><b>{c.author_name}</b>
|
||||||
|
<span className="muted"> · {c.forum_name || '—'} · {c.thread_title}</span></div>
|
||||||
|
<div className="mod-body">{c.body}</div>
|
||||||
|
<div className="mod-actions">
|
||||||
|
<a href={c.thread_url} target="_blank" rel="noreferrer">öffnen</a>
|
||||||
|
<button onClick={() => delComment(c)}>Löschen</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{!data.comments.length && <li className="muted">Noch keine Wortmeldungen.</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="mod-col">
|
||||||
|
<h2>Threads</h2>
|
||||||
|
<ul className="modlist">
|
||||||
|
{data.threads.map((t) => (
|
||||||
|
<li key={t.key} className={t.deleted ? 'gone' : ''}>
|
||||||
|
<div className="mod-head"><b>{t.title}</b>
|
||||||
|
<span className="muted"> · {t.forum_name} · {t.count}</span>
|
||||||
|
{t.locked && <span className="status">gesperrt</span>}
|
||||||
|
{t.deleted && <span className="status">ausgeblendet</span>}</div>
|
||||||
|
<div className="mod-actions">
|
||||||
|
<a href={t.url} target="_blank" rel="noreferrer">öffnen</a>
|
||||||
|
{t.kind !== 'library' && <button onClick={() => toggleLock(t)}>{t.locked ? 'Entsperren' : 'Sperren'}</button>}
|
||||||
|
{!t.deleted && <button onClick={() => delThread(t)}>Ausblenden</button>}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(s) {
|
||||||
|
return (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Mapping Datei-Lesart → Formular ────────────────────────────────────────
|
// ── Mapping Datei-Lesart → Formular ────────────────────────────────────────
|
||||||
function fromRead(r) {
|
function fromRead(r) {
|
||||||
const fm = r.frontmatter || {};
|
const fm = r.frontmatter || {};
|
||||||
|
|||||||
@@ -47,5 +47,18 @@ export const api = {
|
|||||||
listUsers: () => call('/users'),
|
listUsers: () => call('/users'),
|
||||||
createUser: (email, password) => call('/users', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
createUser: (email, password) => call('/users', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||||
setPassword: (id, password) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ password }) }),
|
setPassword: (id, password) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ password }) }),
|
||||||
|
setRole: (id, role) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
||||||
deleteUser: (id) => call(`/users/${id}`, { method: 'DELETE' }),
|
deleteUser: (id) => call(`/users/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Foren-Verwaltung (Admin)
|
||||||
|
listForumsAdmin: () => call('/admin/forums'),
|
||||||
|
createForum: (f) => call('/admin/forums', { method: 'POST', body: JSON.stringify(f) }),
|
||||||
|
updateForum: (id, f) => call(`/admin/forums/${id}`, { method: 'PUT', body: JSON.stringify(f) }),
|
||||||
|
deleteForum: (id) => call(`/admin/forums/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Moderation (Admin + Redakteur)
|
||||||
|
modOverview: () => call('/mod/overview'),
|
||||||
|
lockThread: (key, locked) => call('/mod/thread-lock', { method: 'POST', body: JSON.stringify({ key, locked }) }),
|
||||||
|
deleteThread: (key) => call('/mod/thread-delete', { method: 'POST', body: JSON.stringify({ key }) }),
|
||||||
|
deleteComment: (id) => call(`/comments/${id}`, { method: 'DELETE' }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -158,6 +158,32 @@ label.big input { font-family: var(--serif); font-weight: 600; }
|
|||||||
.profile-card textarea { font-family: var(--serif); font-size: 15px; line-height: 1.6; resize: vertical; }
|
.profile-card textarea { font-family: var(--serif); font-size: 15px; line-height: 1.6; resize: vertical; }
|
||||||
.profile-card .actions { display: flex; }
|
.profile-card .actions { display: flex; }
|
||||||
|
|
||||||
|
.profile-card.wide { max-width: 760px; }
|
||||||
|
.role-select { width: auto; height: 32px; padding: 4px 10px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* ── Foren-Verwaltung ── */
|
||||||
|
.forumlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.forumlist li { display: flex; align-items: center; gap: 10px; padding: 7px 12px; border: 1px solid var(--line); border-radius: 10px; }
|
||||||
|
.forumlist li.is-library { background: var(--panel-2); }
|
||||||
|
.forumlist .fsort { width: 2.2em; text-align: center; color: var(--muted); font-size: 12px; flex: none; }
|
||||||
|
.forumlist .fname { flex: 1; height: 32px; }
|
||||||
|
.forumlist .fcolor { width: 34px; height: 32px; padding: 2px; flex: none; }
|
||||||
|
.forumlist .fslug { color: var(--muted); font-size: 12px; font-family: var(--mono, monospace); flex: none; }
|
||||||
|
.forumlist button { padding: 5px 12px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* ── Moderation (zweispaltig) ── */
|
||||||
|
.moderation { width: 100%; overflow: auto; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 30px 24px; align-content: start; }
|
||||||
|
.mod-col h2 { font-family: var(--serif); font-weight: 600; margin: 0 0 12px; }
|
||||||
|
.modlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.modlist li { padding: 10px 13px; border: 1px solid var(--line); border-radius: 11px; background: var(--panel); }
|
||||||
|
.modlist li.gone { opacity: .5; }
|
||||||
|
.mod-head { font-size: 13.5px; }
|
||||||
|
.mod-head .status { margin-left: 6px; padding: 1px 8px; background: rgba(184,144,47,.14); color: var(--amber); }
|
||||||
|
.mod-body { font-family: var(--serif); font-size: 14.5px; margin: 6px 0; color: var(--text); }
|
||||||
|
.mod-actions { display: flex; align-items: center; gap: 12px; font-size: 13px; }
|
||||||
|
.mod-actions a { color: var(--muted); }
|
||||||
|
.mod-actions button { padding: 4px 11px; font-size: 12.5px; }
|
||||||
|
|
||||||
/* ── Toast ── */
|
/* ── Toast ── */
|
||||||
.toast { position: fixed; bottom: 20px; right: 20px; padding: 11px 18px; border-radius: 11px; color: #fff; cursor: pointer; box-shadow: 0 10px 30px -12px rgba(0,0,0,.4); font-size: 13.5px; max-width: 380px; z-index: 50; }
|
.toast { position: fixed; bottom: 20px; right: 20px; padding: 11px 18px; border-radius: 11px; color: #fff; cursor: pointer; box-shadow: 0 10px 30px -12px rgba(0,0,0,.4); font-size: 13.5px; max-width: 380px; z-index: 50; }
|
||||||
.toast.ok { background: var(--ok); }
|
.toast.ok { background: var(--ok); }
|
||||||
|
|||||||
+28
-5
@@ -1,22 +1,39 @@
|
|||||||
import { supabase } from './supabase.js';
|
import { supabaseAuth } from './supabase.js';
|
||||||
|
|
||||||
// Admins aus der .env (ADMIN_EMAILS=a@x,b@y). Admins sehen/bearbeiten alles.
|
// Rollen-Hierarchie: admin > editor (Redakteur) > user.
|
||||||
|
// - admin: alles (Foren verwalten, moderieren, Nutzer/Rollen, Inhalte)
|
||||||
|
// - editor: moderieren (Wortmeldungen ausblenden/löschen, Threads sperren)
|
||||||
|
// - user: im Forum mitschreiben
|
||||||
|
// Admins aus der .env (ADMIN_EMAILS=a@x,b@y) sind immer Admin (Bootstrap, damit
|
||||||
|
// man sich nicht aussperrt). Zusätzlich kann eine Rolle in app_metadata.role
|
||||||
|
// liegen (im Admin-UI vergeben).
|
||||||
const ADMINS = (process.env.ADMIN_EMAILS || '')
|
const ADMINS = (process.env.ADMIN_EMAILS || '')
|
||||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||||
|
|
||||||
// Verifiziert den Supabase-Access-Token und legt user/email/isAdmin im Kontext ab.
|
export function roleOf(user) {
|
||||||
|
const email = (user?.email || '').toLowerCase();
|
||||||
|
const meta = (user?.app_metadata?.role || '').toLowerCase();
|
||||||
|
if (ADMINS.includes(email) || meta === 'admin') return 'admin';
|
||||||
|
if (meta === 'editor') return 'editor';
|
||||||
|
return 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifiziert den Supabase-Access-Token und legt user/email/role im Kontext ab.
|
||||||
export async function requireAuth(c, next) {
|
export async function requireAuth(c, next) {
|
||||||
const header = c.req.header('Authorization') || '';
|
const header = c.req.header('Authorization') || '';
|
||||||
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||||
if (!token) return c.json({ error: 'Nicht eingeloggt' }, 401);
|
if (!token) return c.json({ error: 'Nicht eingeloggt' }, 401);
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.getUser(token);
|
const { data, error } = await supabaseAuth.auth.getUser(token);
|
||||||
if (error || !data?.user) return c.json({ error: 'Ungültiges Token' }, 401);
|
if (error || !data?.user) return c.json({ error: 'Ungültiges Token' }, 401);
|
||||||
|
|
||||||
const email = (data.user.email || '').toLowerCase();
|
const email = (data.user.email || '').toLowerCase();
|
||||||
|
const role = roleOf(data.user);
|
||||||
c.set('user', data.user);
|
c.set('user', data.user);
|
||||||
c.set('email', email);
|
c.set('email', email);
|
||||||
c.set('isAdmin', ADMINS.includes(email));
|
c.set('role', role);
|
||||||
|
c.set('isAdmin', role === 'admin');
|
||||||
|
c.set('canModerate', role === 'admin' || role === 'editor');
|
||||||
await next();
|
await next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,3 +42,9 @@ export async function requireAdmin(c, next) {
|
|||||||
if (!c.get('isAdmin')) return c.json({ error: 'Nur für Admins' }, 403);
|
if (!c.get('isAdmin')) return c.json({ error: 'Nur für Admins' }, 403);
|
||||||
await next();
|
await next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admins + Redakteure — fürs Moderieren (nach requireAuth einsetzen).
|
||||||
|
export async function requireModerator(c, next) {
|
||||||
|
if (!c.get('canModerate')) return c.json({ error: 'Nur für Moderation' }, 403);
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { supabase } from './supabase.js';
|
||||||
|
import { listEntries } from './files.js';
|
||||||
|
|
||||||
|
// Daten-Schicht für den Dialog (Foren + Threads + Wortmeldungen).
|
||||||
|
// Alle DB-Zugriffe laufen über den Service-Client (umgeht RLS).
|
||||||
|
|
||||||
|
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||||
|
export const LIBRARY_SLUG = 'beitraege';
|
||||||
|
|
||||||
|
export 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Library-Beiträge als Threads in der Kategorie „Beiträge" spiegeln, damit man
|
||||||
|
// auf jeden Beitrag einen Dialog starten kann. Idempotent (upsert über key).
|
||||||
|
export async function syncLibrary() {
|
||||||
|
const { data: forum } = await supabase
|
||||||
|
.from('forums').select('id').eq('slug', LIBRARY_SLUG).single();
|
||||||
|
if (!forum) return;
|
||||||
|
let entries = [];
|
||||||
|
try { entries = (await listEntries()).filter((e) => e.kind === 'beitrag'); } catch { return; }
|
||||||
|
if (!entries.length) return;
|
||||||
|
const rows = entries.map((e) => ({
|
||||||
|
forum_id: forum.id, key: e.url, title: e.title, url: e.url, kind: 'library',
|
||||||
|
}));
|
||||||
|
// Nicht title überschreiben? Doch — Titel kann sich ändern. user_id/locked bleiben.
|
||||||
|
await supabase.from('threads').upsert(rows, { onConflict: 'key', ignoreDuplicates: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wortmeldungen pro Thread-Key aggregieren: { [key]: {count, last} }.
|
||||||
|
async function commentStats() {
|
||||||
|
const { data } = await supabase.from('comments').select('thread,created_at,deleted');
|
||||||
|
const map = {};
|
||||||
|
for (const r of data || []) {
|
||||||
|
if (r.deleted) continue;
|
||||||
|
const t = map[r.thread] || (map[r.thread] = { count: 0, last: r.created_at });
|
||||||
|
t.count += 1;
|
||||||
|
if (r.created_at > t.last) t.last = r.created_at;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle Foren mit Thread-/Wortmeldungs-Zahl und letzter Aktivität.
|
||||||
|
export async function forumsWithCounts() {
|
||||||
|
await syncLibrary();
|
||||||
|
const [{ data: forums }, { data: threads }, stats] = await Promise.all([
|
||||||
|
supabase.from('forums').select('*').order('sort'),
|
||||||
|
supabase.from('threads').select('id,forum_id,key,deleted'),
|
||||||
|
commentStats(),
|
||||||
|
]);
|
||||||
|
const byForum = {};
|
||||||
|
for (const t of threads || []) {
|
||||||
|
if (t.deleted) continue;
|
||||||
|
const f = byForum[t.forum_id] || (byForum[t.forum_id] = { threads: 0, posts: 0, last: '' });
|
||||||
|
f.threads += 1;
|
||||||
|
const s = stats[t.key];
|
||||||
|
if (s) { f.posts += s.count; if (s.last > f.last) f.last = s.last; }
|
||||||
|
}
|
||||||
|
return (forums || []).map((f) => ({
|
||||||
|
...f,
|
||||||
|
thread_count: byForum[f.id]?.threads || 0,
|
||||||
|
post_count: byForum[f.id]?.posts || 0,
|
||||||
|
last_at: byForum[f.id]?.last || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Forum samt seiner Threads (nach letzter Aktivität sortiert).
|
||||||
|
export async function forumWithThreads(slug) {
|
||||||
|
const { data: forum } = await supabase.from('forums').select('*').eq('slug', slug).single();
|
||||||
|
if (!forum) return null;
|
||||||
|
if (forum.kind === 'library') await syncLibrary();
|
||||||
|
const [{ data: threads }, stats] = await Promise.all([
|
||||||
|
supabase.from('threads').select('*').eq('forum_id', forum.id).eq('deleted', false),
|
||||||
|
commentStats(),
|
||||||
|
]);
|
||||||
|
const list = (threads || []).map((t) => ({
|
||||||
|
key: t.key, title: t.title, url: t.url, kind: t.kind, locked: t.locked,
|
||||||
|
author_name: t.author_name, created_at: t.created_at,
|
||||||
|
count: stats[t.key]?.count || 0, last: stats[t.key]?.last || t.created_at,
|
||||||
|
})).sort((a, b) => (b.last || '').localeCompare(a.last || ''));
|
||||||
|
return { forum, threads: list };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letzte Wortmeldungen über alles — für die linke Spalte der Übersicht.
|
||||||
|
export async function recentComments(limit = 20) {
|
||||||
|
await syncLibrary();
|
||||||
|
const [{ data: comments }, { data: threads }, { data: forums }] = await Promise.all([
|
||||||
|
supabase.from('comments').select('id,thread,author_name,body,created_at')
|
||||||
|
.eq('deleted', false).order('created_at', { ascending: false }).limit(limit),
|
||||||
|
supabase.from('threads').select('key,title,url,forum_id,kind'),
|
||||||
|
supabase.from('forums').select('id,slug,name'),
|
||||||
|
]);
|
||||||
|
const tByKey = {}; for (const t of threads || []) tByKey[t.key] = t;
|
||||||
|
const fById = {}; for (const f of forums || []) fById[f.id] = f;
|
||||||
|
return (comments || []).map((c) => {
|
||||||
|
const t = tByKey[c.thread];
|
||||||
|
const f = t ? fById[t.forum_id] : null;
|
||||||
|
return {
|
||||||
|
id: c.id, body: c.body, author_name: c.author_name, created_at: c.created_at,
|
||||||
|
thread_title: t?.title || c.thread,
|
||||||
|
thread_url: t ? (t.kind === 'library' ? t.url : '/dialog/?thread=' + encodeURIComponent(c.thread)) : c.thread,
|
||||||
|
forum_name: f?.name || null, forum_slug: f?.slug || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moderations-Überblick: letzte Wortmeldungen + alle Threads (zum Sperren/Löschen).
|
||||||
|
export async function recentForModeration() {
|
||||||
|
const [comments, { data: threads }, { data: forums }] = await Promise.all([
|
||||||
|
recentComments(50),
|
||||||
|
supabase.from('threads').select('key,title,url,kind,forum_id,locked,deleted,author_name,created_at')
|
||||||
|
.order('created_at', { ascending: false }),
|
||||||
|
supabase.from('forums').select('id,name,slug'),
|
||||||
|
]);
|
||||||
|
const fById = {}; for (const f of forums || []) fById[f.id] = f;
|
||||||
|
const stats = await commentStats();
|
||||||
|
const t = (threads || []).map((x) => ({
|
||||||
|
key: x.key, title: x.title, url: x.url, kind: x.kind, locked: x.locked, deleted: x.deleted,
|
||||||
|
author_name: x.author_name, created_at: x.created_at,
|
||||||
|
forum_name: fById[x.forum_id]?.name || null,
|
||||||
|
count: stats[x.key]?.count || 0,
|
||||||
|
}));
|
||||||
|
return { comments, threads: t };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neuen Thread in einem Forum anlegen (+ erste Wortmeldung). Gibt den Thread zurück.
|
||||||
|
export async function createThread({ forumId, forumSlug, title, body, user, email }) {
|
||||||
|
let fid = forumId;
|
||||||
|
if (!fid && forumSlug) {
|
||||||
|
const { data: f } = await supabase.from('forums').select('id,kind').eq('slug', forumSlug).single();
|
||||||
|
if (!f) return { error: 'Forum unbekannt' };
|
||||||
|
if (f.kind === 'library') return { error: 'In Beiträge entstehen Threads automatisch' };
|
||||||
|
fid = f.id;
|
||||||
|
}
|
||||||
|
if (!fid) return { error: 'Forum nötig' };
|
||||||
|
if (!title || !title.trim()) return { error: 'Titel nötig' };
|
||||||
|
if (!body || !body.trim()) return { error: 'Erster Beitrag nötig' };
|
||||||
|
|
||||||
|
const prof = await profileFor(email);
|
||||||
|
const name = prof?.name || email.split('@')[0];
|
||||||
|
const key = 't/' + randomUUID();
|
||||||
|
const { data: thread, error: e1 } = await supabase.from('threads').insert({
|
||||||
|
forum_id: fid, key, title: title.trim(), url: '/dialog/?thread=' + encodeURIComponent(key),
|
||||||
|
kind: 'forum', author_name: name, user_id: user.id,
|
||||||
|
}).select('*').single();
|
||||||
|
if (e1) return { error: e1.message };
|
||||||
|
const { error: e2 } = await supabase.from('comments').insert({
|
||||||
|
thread: key, user_id: user.id, author_name: name,
|
||||||
|
author_avatar: prof?.avatar || null, body: body.trim(),
|
||||||
|
});
|
||||||
|
if (e2) return { error: e2.message };
|
||||||
|
return { thread };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ist ein Thread gesperrt? (verhindert neue Wortmeldungen)
|
||||||
|
export async function threadLocked(key) {
|
||||||
|
const { data } = await supabase.from('threads').select('locked').eq('key', key).single();
|
||||||
|
return !!data?.locked;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread-Metadaten für die Thread-Ansicht (Titel, Forum-Rücklink, Lock-Status).
|
||||||
|
export async function threadMeta(key) {
|
||||||
|
const { data: t } = await supabase
|
||||||
|
.from('threads').select('title,url,kind,locked,forum_id').eq('key', key).single();
|
||||||
|
if (!t) return null;
|
||||||
|
let forum = null;
|
||||||
|
if (t.forum_id) {
|
||||||
|
const { data: f } = await supabase.from('forums').select('slug,name').eq('id', t.forum_id).single();
|
||||||
|
forum = f || null;
|
||||||
|
}
|
||||||
|
return { title: t.title, url: t.url, kind: t.kind, locked: !!t.locked, forum };
|
||||||
|
}
|
||||||
+15
-3
@@ -8,8 +8,12 @@ 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, listThreads, createComment, deleteComment, login } from './routes/comments.js';
|
import { listComments, createComment, deleteComment, login } from './routes/comments.js';
|
||||||
|
import {
|
||||||
|
listForums, showForum, recent, threadInfo, newThread, mod, adminForums,
|
||||||
|
} from './routes/dialog.js';
|
||||||
import { requireAuth } from './auth.js';
|
import { requireAuth } from './auth.js';
|
||||||
|
import { syncLibrary } from './dialog-store.js';
|
||||||
|
|
||||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||||
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
|
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
|
||||||
@@ -21,13 +25,19 @@ const app = new Hono();
|
|||||||
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' }));
|
||||||
// Öffentlich (ohne Login): Dialog lesen, Übersicht, Login fürs Dialog-Widget.
|
// Öffentlich (ohne Login): Dialog lesen, Übersicht, Login fürs Dialog-Widget.
|
||||||
app.get('/api/comments', listComments);
|
app.get('/api/comments', listComments);
|
||||||
app.get('/api/threads', listThreads);
|
app.get('/api/forums', listForums);
|
||||||
|
app.get('/api/forums/:slug', showForum);
|
||||||
|
app.get('/api/recent', recent);
|
||||||
|
app.get('/api/thread', threadInfo);
|
||||||
app.post('/api/auth/login', login);
|
app.post('/api/auth/login', login);
|
||||||
// Alles weitere unter /api/* braucht ein gültiges Supabase-Token.
|
// 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'), role: c.get('role'), isAdmin: c.get('isAdmin'), canModerate: c.get('canModerate') }));
|
||||||
app.post('/api/comments', createComment);
|
app.post('/api/comments', createComment);
|
||||||
app.delete('/api/comments/:id', deleteComment);
|
app.delete('/api/comments/:id', deleteComment);
|
||||||
|
app.post('/api/threads', newThread);
|
||||||
|
app.route('/api/mod', mod);
|
||||||
|
app.route('/api/admin/forums', adminForums);
|
||||||
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);
|
||||||
@@ -63,4 +73,6 @@ app.use('/*', serveStatic({ root: `${SITE_DIR}/public` }));
|
|||||||
|
|
||||||
serve({ fetch: app.fetch, port: PORT }, (info) => {
|
serve({ fetch: app.fetch, port: PORT }, (info) => {
|
||||||
console.log(`OPENBUREAU CMS läuft auf :${info.port} — Site + API + /_preview`);
|
console.log(`OPENBUREAU CMS läuft auf :${info.port} — Site + API + /_preview`);
|
||||||
|
// Library-Beiträge als Threads in „Beiträge" spiegeln (nicht blockierend).
|
||||||
|
syncLibrary().catch((e) => console.error('syncLibrary:', e?.message || e));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { supabase, supabaseAuth } from '../supabase.js';
|
||||||
import path from 'node:path';
|
import { roleOf } from '../auth.js';
|
||||||
import { supabase } from '../supabase.js';
|
import { profileFor, threadLocked } from '../dialog-store.js';
|
||||||
import { listEntries } from '../files.js';
|
|
||||||
|
|
||||||
// Dialog: flache Wortmeldungen pro Thread (= Beitrags-Pfad), optionaler Bezug.
|
// Dialog: flache Wortmeldungen pro Thread (= Thread-Key), 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';
|
const COLS = 'id,thread,parent_id,author_name,author_avatar,body,created_at,deleted';
|
||||||
|
|
||||||
@@ -27,31 +17,13 @@ export async function listComments(c) {
|
|||||||
return c.json(out);
|
return c.json(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ÖFFENTLICH: Übersicht aller begonnenen Dialoge (Threads mit Wortmeldungen).
|
|
||||||
export async function listThreads(c) {
|
|
||||||
const { data, error } = await supabase.from('comments').select('thread,created_at,deleted');
|
|
||||||
if (error) return c.json({ error: error.message }, 500);
|
|
||||||
const map = {};
|
|
||||||
for (const r of data || []) {
|
|
||||||
if (r.deleted) continue;
|
|
||||||
const t = map[r.thread] || (map[r.thread] = { thread: r.thread, count: 0, last: r.created_at });
|
|
||||||
t.count += 1;
|
|
||||||
if (r.created_at > t.last) t.last = r.created_at;
|
|
||||||
}
|
|
||||||
let titles = {};
|
|
||||||
try { (await listEntries()).forEach((e) => { titles[e.url] = e.title; }); } catch { /* egal */ }
|
|
||||||
const out = Object.values(map)
|
|
||||||
.map((t) => ({ ...t, title: titles[t.thread] || t.thread }))
|
|
||||||
.sort((a, b) => (b.last || '').localeCompare(a.last || ''));
|
|
||||||
return c.json(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
// EINGELOGGT: Wortmeldung schreiben.
|
// EINGELOGGT: Wortmeldung schreiben.
|
||||||
export async function createComment(c) {
|
export async function createComment(c) {
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
const email = c.get('email');
|
const email = c.get('email');
|
||||||
const { thread, body, parent_id } = await c.req.json();
|
const { thread, body, parent_id } = await c.req.json();
|
||||||
if (!thread || !body || !body.trim()) return c.json({ error: 'thread und Text nötig' }, 400);
|
if (!thread || !body || !body.trim()) return c.json({ error: 'thread und Text nötig' }, 400);
|
||||||
|
if (await threadLocked(thread)) return c.json({ error: 'Thread ist gesperrt' }, 403);
|
||||||
|
|
||||||
const prof = await profileFor(email);
|
const prof = await profileFor(email);
|
||||||
const row = {
|
const row = {
|
||||||
@@ -67,14 +39,14 @@ export async function createComment(c) {
|
|||||||
return c.json(data, 201);
|
return c.json(data, 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
// EINGELOGGT: eigene Wortmeldung (oder als Admin jede) löschen.
|
// EINGELOGGT: eigene Wortmeldung löschen; Moderation (Admin/Redakteur) jede.
|
||||||
export async function deleteComment(c) {
|
export async function deleteComment(c) {
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
const isAdmin = c.get('isAdmin');
|
const canModerate = c.get('canModerate');
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const { data: row, error: e1 } = await supabase.from('comments').select('user_id').eq('id', id).single();
|
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 (e1 || !row) return c.json({ error: 'Nicht gefunden' }, 404);
|
||||||
if (!isAdmin && row.user_id !== user.id) return c.json({ error: 'Kein Recht' }, 403);
|
if (!canModerate && row.user_id !== user.id) return c.json({ error: 'Kein Recht' }, 403);
|
||||||
const { error } = await supabase.from('comments').update({ deleted: true }).eq('id', id);
|
const { error } = await supabase.from('comments').update({ deleted: true }).eq('id', id);
|
||||||
if (error) return c.json({ error: error.message }, 400);
|
if (error) return c.json({ error: error.message }, 400);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
@@ -84,12 +56,13 @@ export async function deleteComment(c) {
|
|||||||
export async function login(c) {
|
export async function login(c) {
|
||||||
const { email, password } = await c.req.json();
|
const { email, password } = await c.req.json();
|
||||||
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
||||||
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
|
const { data, error } = await supabaseAuth.auth.signInWithPassword({ email, password });
|
||||||
if (error) return c.json({ error: error.message }, 401);
|
if (error) return c.json({ error: error.message }, 401);
|
||||||
const prof = await profileFor((data.user.email || '').toLowerCase());
|
const prof = await profileFor((data.user.email || '').toLowerCase());
|
||||||
return c.json({
|
return c.json({
|
||||||
access_token: data.session.access_token,
|
access_token: data.session.access_token,
|
||||||
email: data.user.email,
|
email: data.user.email,
|
||||||
name: prof?.name || (data.user.email || '').split('@')[0],
|
name: prof?.name || (data.user.email || '').split('@')[0],
|
||||||
|
role: roleOf(data.user),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { supabase } from '../supabase.js';
|
||||||
|
import { requireAdmin, requireModerator } from '../auth.js';
|
||||||
|
import {
|
||||||
|
forumsWithCounts, forumWithThreads, recentComments, createThread, recentForModeration, threadMeta,
|
||||||
|
} from '../dialog-store.js';
|
||||||
|
|
||||||
|
// ── Öffentliche Lese-Handler ─────────────────────────────────────────────
|
||||||
|
export async function listForums(c) {
|
||||||
|
try { return c.json(await forumsWithCounts()); }
|
||||||
|
catch (e) { return c.json({ error: String(e) }, 500); }
|
||||||
|
}
|
||||||
|
export async function showForum(c) {
|
||||||
|
const data = await forumWithThreads(c.req.param('slug'));
|
||||||
|
if (!data) return c.json({ error: 'Forum nicht gefunden' }, 404);
|
||||||
|
return c.json(data);
|
||||||
|
}
|
||||||
|
export async function recent(c) {
|
||||||
|
return c.json(await recentComments(Number(c.req.query('limit')) || 20));
|
||||||
|
}
|
||||||
|
export async function threadInfo(c) {
|
||||||
|
const key = c.req.query('key');
|
||||||
|
if (!key) return c.json({ error: 'key fehlt' }, 400);
|
||||||
|
const meta = await threadMeta(key);
|
||||||
|
if (!meta) return c.json({ error: 'Thread nicht gefunden' }, 404);
|
||||||
|
return c.json(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Eingeloggt: neuen Thread starten ─────────────────────────────────────
|
||||||
|
export async function newThread(c) {
|
||||||
|
const user = c.get('user');
|
||||||
|
const email = c.get('email');
|
||||||
|
const { forum_id, forum_slug, title, body } = await c.req.json();
|
||||||
|
const res = await createThread({ forumId: forum_id, forumSlug: forum_slug, title, body, user, email });
|
||||||
|
if (res.error) return c.json({ error: res.error }, 400);
|
||||||
|
return c.json(res.thread, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Moderation (Admin + Redakteur) ───────────────────────────────────────
|
||||||
|
export const mod = new Hono();
|
||||||
|
mod.use('*', requireModerator);
|
||||||
|
// Feed: letzte Wortmeldungen + alle Threads (zum Moderieren/Sperren).
|
||||||
|
mod.get('/overview', async (c) => c.json(await recentForModeration()));
|
||||||
|
// Thread sperren/entsperren.
|
||||||
|
mod.post('/thread-lock', async (c) => {
|
||||||
|
const { key, locked } = await c.req.json();
|
||||||
|
if (!key) return c.json({ error: 'key nötig' }, 400);
|
||||||
|
const { error } = await supabase.from('threads').update({ locked: !!locked }).eq('key', key);
|
||||||
|
if (error) return c.json({ error: error.message }, 400);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
// Thread ausblenden (löschen).
|
||||||
|
mod.post('/thread-delete', async (c) => {
|
||||||
|
const { key } = await c.req.json();
|
||||||
|
if (!key) return c.json({ error: 'key nötig' }, 400);
|
||||||
|
const { error } = await supabase.from('threads').update({ deleted: true }).eq('key', key);
|
||||||
|
if (error) return c.json({ error: error.message }, 400);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Foren-Verwaltung (nur Admin) ─────────────────────────────────────────
|
||||||
|
export const adminForums = new Hono();
|
||||||
|
adminForums.use('*', requireAdmin);
|
||||||
|
adminForums.get('/', async (c) => {
|
||||||
|
const { data, error } = await supabase.from('forums').select('*').order('sort');
|
||||||
|
if (error) return c.json({ error: error.message }, 500);
|
||||||
|
return c.json(data || []);
|
||||||
|
});
|
||||||
|
adminForums.post('/', async (c) => {
|
||||||
|
const { slug, name, description, color, sort } = await c.req.json();
|
||||||
|
if (!slug || !name) return c.json({ error: 'slug und name nötig' }, 400);
|
||||||
|
const row = { slug: String(slug).trim(), name: String(name).trim(),
|
||||||
|
description: description || '', color: color || null, sort: Number(sort) || 0 };
|
||||||
|
const { data, error } = await supabase.from('forums').insert(row).select('*').single();
|
||||||
|
if (error) return c.json({ error: error.message }, 400);
|
||||||
|
return c.json(data, 201);
|
||||||
|
});
|
||||||
|
adminForums.put('/:id', async (c) => {
|
||||||
|
const patch = await c.req.json();
|
||||||
|
const allowed = {};
|
||||||
|
for (const k of ['name', 'description', 'color', 'sort', 'slug']) if (k in patch) allowed[k] = patch[k];
|
||||||
|
const { data, error } = await supabase.from('forums').update(allowed).eq('id', c.req.param('id')).select('*').single();
|
||||||
|
if (error) return c.json({ error: error.message }, 400);
|
||||||
|
return c.json(data);
|
||||||
|
});
|
||||||
|
adminForums.delete('/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const { data: f } = await supabase.from('forums').select('kind').eq('id', id).single();
|
||||||
|
if (f?.kind === 'library') return c.json({ error: 'Beiträge-Kategorie kann nicht gelöscht werden' }, 400);
|
||||||
|
const { error } = await supabase.from('forums').delete().eq('id', id);
|
||||||
|
if (error) return c.json({ error: error.message }, 400);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
+22
-10
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { supabase } from '../supabase.js';
|
import { supabase } from '../supabase.js';
|
||||||
import { requireAdmin } from '../auth.js';
|
import { requireAdmin, roleOf } from '../auth.js';
|
||||||
|
|
||||||
// Autoren-/Nutzerverwaltung über die GoTrue-Admin-API (Service-Key). Nur Admins.
|
// Autoren-/Nutzerverwaltung über die GoTrue-Admin-API (Service-Key). Nur Admins.
|
||||||
const ADMINS = (process.env.ADMIN_EMAILS || '')
|
const ADMINS = (process.env.ADMIN_EMAILS || '')
|
||||||
@@ -12,12 +12,18 @@ users.use('*', requireAdmin);
|
|||||||
users.get('/', async (c) => {
|
users.get('/', async (c) => {
|
||||||
const { data, error } = await supabase.auth.admin.listUsers();
|
const { data, error } = await supabase.auth.admin.listUsers();
|
||||||
if (error) return c.json({ error: error.message }, 500);
|
if (error) return c.json({ error: error.message }, 500);
|
||||||
const list = (data?.users || []).map((u) => ({
|
const list = (data?.users || []).map((u) => {
|
||||||
id: u.id,
|
const role = roleOf(u);
|
||||||
email: u.email,
|
return {
|
||||||
created_at: u.created_at,
|
id: u.id,
|
||||||
isAdmin: ADMINS.includes((u.email || '').toLowerCase()),
|
email: u.email,
|
||||||
}));
|
created_at: u.created_at,
|
||||||
|
role,
|
||||||
|
isAdmin: role === 'admin',
|
||||||
|
// Admins aus der .env lassen sich nicht per UI herabstufen.
|
||||||
|
fixedAdmin: ADMINS.includes((u.email || '').toLowerCase()),
|
||||||
|
};
|
||||||
|
});
|
||||||
return c.json(list);
|
return c.json(list);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,9 +36,15 @@ users.post('/', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
users.put('/:id', async (c) => {
|
users.put('/:id', async (c) => {
|
||||||
const { password } = await c.req.json();
|
const { password, role } = await c.req.json();
|
||||||
if (!password) return c.json({ error: 'Passwort nötig' }, 400);
|
const patch = {};
|
||||||
const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), { password });
|
if (password) patch.password = password;
|
||||||
|
if (role) {
|
||||||
|
if (!['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
|
||||||
|
patch.app_metadata = { role };
|
||||||
|
}
|
||||||
|
if (!Object.keys(patch).length) return c.json({ error: 'Nichts zu ändern' }, 400);
|
||||||
|
const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), patch);
|
||||||
if (error) return c.json({ error: error.message }, 400);
|
if (error) return c.json({ error: error.message }, 400);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-4
@@ -8,7 +8,14 @@ if (!url || !key) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service-Role-Key: server-seitig, umgeht RLS. Niemals ins Frontend geben.
|
const opts = { auth: { persistSession: false, autoRefreshToken: false } };
|
||||||
export const supabase = createClient(url, key, {
|
|
||||||
auth: { persistSession: false, autoRefreshToken: false },
|
// Daten-Client: Service-Role-Key, umgeht RLS. NUR für DB-Zugriffe (from/insert/…).
|
||||||
});
|
// Wichtig: hier niemals signInWithPassword aufrufen — das schaltet den
|
||||||
|
// Authorization-Header des Clients prozessweit auf das User-Token um (SIGNED_IN),
|
||||||
|
// wodurch anschließende Inserts als role=authenticated laufen und an RLS scheitern.
|
||||||
|
export const supabase = createClient(url, key, opts);
|
||||||
|
|
||||||
|
// Eigener Client nur für Auth (Login, Token-Prüfung). Getrennt, damit ein
|
||||||
|
// signInWithPassword den Daten-Client oben nicht „vergiftet". Niemals ins Frontend.
|
||||||
|
export const supabaseAuth = createClient(url, key, opts);
|
||||||
|
|||||||
@@ -49,3 +49,52 @@ create table if not exists public.comments (
|
|||||||
create index if not exists comments_thread_idx on public.comments (thread, created_at);
|
create index if not exists comments_thread_idx on public.comments (thread, created_at);
|
||||||
alter table public.comments enable row level security;
|
alter table public.comments enable row level security;
|
||||||
grant all on public.comments to anon, authenticated, service_role;
|
grant all on public.comments to anon, authenticated, service_role;
|
||||||
|
|
||||||
|
-- ── Foren / Subforen ────────────────────────────────────────────────────
|
||||||
|
-- Kategorien, in denen Threads leben. Admin-verwaltet. `kind=library` ist die
|
||||||
|
-- Sonder-Kategorie, in der die Library-Beiträge automatisch als Threads landen.
|
||||||
|
create table if not exists public.forums (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
slug text not null unique,
|
||||||
|
name text not null,
|
||||||
|
description text default '',
|
||||||
|
color text, -- optionale Akzentfarbe
|
||||||
|
sort int not null default 0,
|
||||||
|
kind text not null default 'forum', -- forum | library
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
alter table public.forums enable row level security;
|
||||||
|
grant all on public.forums to anon, authenticated, service_role;
|
||||||
|
|
||||||
|
-- ── Threads (Diskussionen) ──────────────────────────────────────────────
|
||||||
|
-- key = stabiler Bezeichner, den comments.thread referenziert:
|
||||||
|
-- Forum-Thread → 't/<uuid>'
|
||||||
|
-- Library-Beitrag → Beitragspfad (z.B. /library/software/stack/)
|
||||||
|
create table if not exists public.threads (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
forum_id uuid references public.forums(id) on delete cascade,
|
||||||
|
key text not null unique,
|
||||||
|
title text not null,
|
||||||
|
url text, -- Ziel-Link (Library: Beitragspfad)
|
||||||
|
kind text not null default 'forum', -- forum | library
|
||||||
|
author_name text,
|
||||||
|
user_id uuid,
|
||||||
|
locked boolean not null default false,
|
||||||
|
deleted boolean not null default false,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
create index if not exists threads_forum_idx on public.threads (forum_id);
|
||||||
|
alter table public.threads enable row level security;
|
||||||
|
grant all on public.threads to anon, authenticated, service_role;
|
||||||
|
|
||||||
|
-- Seed-Kategorien (idempotent; im Admin umbenenn-/erweiterbar).
|
||||||
|
insert into public.forums (slug, name, sort, kind) values
|
||||||
|
('allgemein', 'Allgemein', 10, 'forum'),
|
||||||
|
('projekte', 'Projekte', 20, 'forum'),
|
||||||
|
('technik', 'Technik', 30, 'forum'),
|
||||||
|
('off-topic', 'Off-Topic', 40, 'forum')
|
||||||
|
on conflict (slug) do nothing;
|
||||||
|
-- Sonder-Kategorie für die Library-Beiträge.
|
||||||
|
insert into public.forums (slug, name, sort, kind) values
|
||||||
|
('beitraege', 'Beiträge', 0, 'library')
|
||||||
|
on conflict (slug) do nothing;
|
||||||
|
|||||||
@@ -4,16 +4,4 @@
|
|||||||
<section id="ob-dialog"></section>
|
<section id="ob-dialog"></section>
|
||||||
</div>
|
</div>
|
||||||
<script defer src="/dialog.js"></script>
|
<script defer src="/dialog.js"></script>
|
||||||
<script>
|
|
||||||
/* Kontext: Rücklink zum diskutierten Beitrag (thread = dessen Pfad). */
|
|
||||||
(function () {
|
|
||||||
var t = new URLSearchParams(location.search).get('thread');
|
|
||||||
var el = document.getElementById('dialog-context');
|
|
||||||
if (t && el) {
|
|
||||||
var a = document.createElement('a');
|
|
||||||
a.href = t; a.textContent = '← zum Beitrag';
|
|
||||||
el.appendChild(a);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
+264
-136
@@ -1,46 +1,26 @@
|
|||||||
/* OPENBUREAU Dialog — flache Wortmeldungen pro Beitrag.
|
/* OPENBUREAU Dialog — Foren, Threads & flache Wortmeldungen.
|
||||||
Lesen öffentlich, Mitreden nach Login (eingeladene User). Kein Build nötig. */
|
Lesen öffentlich; Mitreden nach Login. Kein Build nötig.
|
||||||
|
Routen über Query-Parameter:
|
||||||
|
/dialog/ → Übersicht (Split-View: letzte Beiträge | Foren)
|
||||||
|
/dialog/?forum=<slug> → ein Forum mit seinen Threads
|
||||||
|
/dialog/?thread=<key> → ein Thread mit Wortmeldungen
|
||||||
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
const root = document.getElementById('ob-dialog');
|
const root = document.getElementById('ob-dialog');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const thread = root.dataset.thread || new URLSearchParams(location.search).get('thread') || '';
|
const ctxEl = document.getElementById('dialog-context');
|
||||||
if (!thread) { renderOverview(); return; }
|
|
||||||
|
|
||||||
// Übersicht aller begonnenen Dialoge (wenn kein Thema gewählt).
|
const TKEY = 'ob_dialog_token', NKEY = 'ob_dialog_name', RKEY = 'ob_dialog_role';
|
||||||
function renderOverview() {
|
|
||||||
root.innerHTML = '';
|
|
||||||
const h = document.createElement('h2'); h.className = 'dialog-title'; h.textContent = 'Dialoge';
|
|
||||||
const list = document.createElement('div'); list.className = 'dialog-overview';
|
|
||||||
root.append(h, list);
|
|
||||||
fetch('/api/threads').then((r) => (r.ok ? r.json() : [])).then((rows) => {
|
|
||||||
if (!rows.length) {
|
|
||||||
const e = document.createElement('p'); e.className = 'dialog-empty';
|
|
||||||
e.textContent = 'Noch keine Dialoge begonnen.';
|
|
||||||
list.appendChild(e); return;
|
|
||||||
}
|
|
||||||
rows.forEach((t) => {
|
|
||||||
const a = document.createElement('a'); a.className = 'dialog-overview-item';
|
|
||||||
a.href = '/dialog/?thread=' + encodeURIComponent(t.thread);
|
|
||||||
const ti = document.createElement('span'); ti.className = 'dialog-ov-title'; ti.textContent = t.title;
|
|
||||||
const mt = document.createElement('span'); mt.className = 'dialog-ov-meta';
|
|
||||||
mt.textContent = t.count + (t.count === 1 ? ' Wortmeldung' : ' Wortmeldungen');
|
|
||||||
a.append(ti, mt); list.appendChild(a);
|
|
||||||
});
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
const TKEY = 'ob_dialog_token', NKEY = 'ob_dialog_name';
|
|
||||||
let token = localStorage.getItem(TKEY);
|
let token = localStorage.getItem(TKEY);
|
||||||
let myName = localStorage.getItem(NKEY);
|
let myName = localStorage.getItem(NKEY);
|
||||||
let replyTo = null;
|
let myRole = localStorage.getItem(RKEY) || 'user';
|
||||||
let textarea = null;
|
const canModerate = () => token && (myRole === 'admin' || myRole === 'editor');
|
||||||
|
|
||||||
root.innerHTML = '';
|
const params = new URLSearchParams(location.search);
|
||||||
const title = document.createElement('h2');
|
const threadKey = params.get('thread');
|
||||||
title.className = 'dialog-title'; title.textContent = 'Dialog';
|
const forumSlug = params.get('forum');
|
||||||
const list = document.createElement('div'); list.className = 'dialog-list';
|
|
||||||
const composer = document.createElement('div'); composer.className = 'dialog-composer';
|
|
||||||
root.append(title, list, composer);
|
|
||||||
|
|
||||||
|
const el = (tag, cls, txt) => { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; };
|
||||||
function fmt(ts) {
|
function fmt(ts) {
|
||||||
const d = new Date(ts), s = (Date.now() - d) / 1000;
|
const d = new Date(ts), s = (Date.now() - d) / 1000;
|
||||||
if (s < 60) return 'gerade eben';
|
if (s < 60) return 'gerade eben';
|
||||||
@@ -48,122 +28,270 @@
|
|||||||
if (s < 86400) return Math.floor(s / 3600) + ' Std.';
|
if (s < 86400) return Math.floor(s / 3600) + ' Std.';
|
||||||
return d.toLocaleDateString('de-CH');
|
return d.toLocaleDateString('de-CH');
|
||||||
}
|
}
|
||||||
|
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 });
|
||||||
|
|
||||||
let lastSig = '';
|
// ── Auth ────────────────────────────────────────────────────────────────
|
||||||
async function load() {
|
async function doLogin(email, password, after) {
|
||||||
let data = [];
|
const r = await api('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) });
|
||||||
try {
|
if (!r.ok) { alert(r.body.error || 'Login fehlgeschlagen'); return; }
|
||||||
const res = await fetch('/api/comments?thread=' + encodeURIComponent(thread));
|
token = r.body.access_token; myName = r.body.name || ''; myRole = r.body.role || 'user';
|
||||||
if (res.ok) data = await res.json();
|
localStorage.setItem(TKEY, token); localStorage.setItem(NKEY, myName); localStorage.setItem(RKEY, myRole);
|
||||||
} catch { return; /* offline: alte Ansicht behalten */ }
|
if (after) after();
|
||||||
// Nur neu rendern, wenn sich wirklich etwas geändert hat (kein Flackern).
|
}
|
||||||
const sig = (token ? 'in:' : 'out:') + data.map((c) => c.id + (c.deleted ? 'd' : '')).join(',');
|
function logout(after) {
|
||||||
if (sig !== lastSig) { lastSig = sig; render(data); }
|
token = null; myName = null; myRole = 'user';
|
||||||
|
localStorage.removeItem(TKEY); localStorage.removeItem(NKEY); localStorage.removeItem(RKEY);
|
||||||
|
if (after) after();
|
||||||
|
}
|
||||||
|
// Subtiles Login: dezente Zeile, klappt zum kompakten Formular auf.
|
||||||
|
function loginInline(container, after, label) {
|
||||||
|
let open = false;
|
||||||
|
function paint() {
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!open) {
|
||||||
|
const link = el('button', 'dialog-loginlink', label || 'Zum Mitreden anmelden');
|
||||||
|
link.onclick = () => { open = true; paint(); };
|
||||||
|
container.appendChild(link);
|
||||||
|
} else {
|
||||||
|
const form = el('div', 'dialog-loginform');
|
||||||
|
const em = el('input', 'dialog-input'); em.type = 'email'; em.placeholder = 'E-Mail';
|
||||||
|
const pw = el('input', 'dialog-input'); pw.type = 'password'; pw.placeholder = 'Passwort';
|
||||||
|
const btn = el('button', 'dialog-send', 'Anmelden');
|
||||||
|
btn.onclick = () => doLogin(em.value, pw.value, after);
|
||||||
|
pw.onkeydown = (e) => { if (e.key === 'Enter') doLogin(em.value, pw.value, after); };
|
||||||
|
const cancel = el('button', 'dialog-loginlink dialog-logincancel', 'Abbrechen');
|
||||||
|
cancel.onclick = () => { open = false; paint(); };
|
||||||
|
form.append(em, pw, btn, cancel); container.appendChild(form); em.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paint();
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(data) {
|
// ── Übersicht: Split-View ─────────────────────────────────────────────────
|
||||||
list.innerHTML = '';
|
function renderOverview() {
|
||||||
const names = {}; data.forEach((c) => { names[c.id] = c.author_name; });
|
if (ctxEl) ctxEl.textContent = '';
|
||||||
if (!data.length) {
|
root.innerHTML = '';
|
||||||
const e = document.createElement('p'); e.className = 'dialog-empty';
|
const grid = el('div', 'dialog-split');
|
||||||
e.textContent = 'Noch keine Wortmeldungen — beginne den Dialog.';
|
const left = el('div', 'dialog-recent');
|
||||||
list.appendChild(e); return;
|
const right = el('div', 'dialog-forums');
|
||||||
}
|
left.appendChild(el('h2', 'dialog-title', 'Letzte Wortmeldungen'));
|
||||||
data.forEach((c) => {
|
right.appendChild(el('h2', 'dialog-title', 'Foren'));
|
||||||
const card = document.createElement('article'); card.className = 'dialog-card';
|
grid.append(left, right); root.appendChild(grid);
|
||||||
const head = document.createElement('header'); head.className = 'dialog-card-head';
|
|
||||||
const av = document.createElement('span'); av.className = 'dialog-avatar';
|
api('/api/recent?limit=15').then((r) => {
|
||||||
if (c.author_avatar) av.style.backgroundImage = 'url(' + c.author_avatar + ')';
|
const rows = r.body || [];
|
||||||
else av.textContent = (c.author_name || '?').slice(0, 1).toUpperCase();
|
if (!rows.length) { left.appendChild(el('p', 'dialog-empty', 'Noch keine Wortmeldungen.')); return; }
|
||||||
const meta = document.createElement('div'); meta.className = 'dialog-meta';
|
const list = el('div', 'dialog-recent-list');
|
||||||
const nm = document.createElement('span'); nm.className = 'dialog-name'; nm.textContent = c.author_name || 'Unbekannt';
|
rows.forEach((c) => {
|
||||||
const tm = document.createElement('time'); tm.className = 'dialog-time'; tm.textContent = fmt(c.created_at);
|
const a = el('a', 'dialog-recent-item'); a.href = c.thread_url;
|
||||||
meta.append(nm, tm);
|
const top = el('div', 'dialog-recent-top');
|
||||||
if (c.parent_id && names[c.parent_id]) {
|
top.append(el('span', 'dialog-recent-author', c.author_name || 'Unbekannt'),
|
||||||
const rp = document.createElement('span'); rp.className = 'dialog-replyto'; rp.textContent = '↳ ' + names[c.parent_id];
|
el('span', 'dialog-recent-meta', (c.forum_name ? c.forum_name + ' · ' : '') + fmt(c.created_at)));
|
||||||
meta.appendChild(rp);
|
const tt = el('div', 'dialog-recent-thread', c.thread_title);
|
||||||
}
|
const bd = el('div', 'dialog-recent-body', c.body);
|
||||||
head.append(av, meta); card.appendChild(head);
|
a.append(top, tt, bd); list.appendChild(a);
|
||||||
const bd = document.createElement('div'); bd.className = 'dialog-body'; bd.textContent = c.body; card.appendChild(bd);
|
});
|
||||||
if (token && !c.deleted) {
|
left.appendChild(list);
|
||||||
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(); };
|
api('/api/forums').then((r) => {
|
||||||
const del = document.createElement('button'); del.textContent = 'Löschen'; del.onclick = () => remove(c.id);
|
const rows = r.body || [];
|
||||||
actions.append(rep, del); card.appendChild(actions);
|
const list = el('div', 'dialog-forum-list');
|
||||||
}
|
rows.forEach((f) => {
|
||||||
list.appendChild(card);
|
const a = el('a', 'dialog-forum-item'); a.href = '/dialog/?forum=' + encodeURIComponent(f.slug);
|
||||||
|
if (f.color) a.style.setProperty('--forum-accent', f.color);
|
||||||
|
const nm = el('span', 'dialog-forum-name', f.name);
|
||||||
|
const mt = el('span', 'dialog-forum-meta',
|
||||||
|
f.thread_count + (f.thread_count === 1 ? ' Thread' : ' Threads') + ' · ' + f.post_count + ' Beiträge');
|
||||||
|
a.append(nm, mt);
|
||||||
|
if (f.description) a.appendChild(el('span', 'dialog-forum-desc', f.description));
|
||||||
|
list.appendChild(a);
|
||||||
|
});
|
||||||
|
right.appendChild(list);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderComposer() {
|
// ── Forum-Ansicht: Threads + neuer Thread ─────────────────────────────────
|
||||||
composer.innerHTML = '';
|
function renderForum(slug) {
|
||||||
if (token) {
|
root.innerHTML = '';
|
||||||
|
if (ctxEl) { ctxEl.innerHTML = ''; const b = el('a', null, '← Dialoge'); b.href = '/dialog/'; ctxEl.appendChild(b); }
|
||||||
|
api('/api/forums/' + encodeURIComponent(slug)).then((r) => {
|
||||||
|
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');
|
||||||
|
head.appendChild(el('h2', 'dialog-title', forum.name));
|
||||||
|
if (forum.color) head.style.setProperty('--forum-accent', forum.color);
|
||||||
|
root.appendChild(head);
|
||||||
|
if (forum.description) root.appendChild(el('p', 'dialog-forum-desc', forum.description));
|
||||||
|
|
||||||
|
// Neuer Thread (nur Forum-Kategorien, nicht „Beiträge").
|
||||||
|
if (forum.kind !== 'library') {
|
||||||
|
const newBox = el('div', 'dialog-newthread');
|
||||||
|
root.appendChild(newBox);
|
||||||
|
renderNewThread(newBox, forum.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = el('div', 'dialog-thread-list');
|
||||||
|
if (!threads.length) list.appendChild(el('p', 'dialog-empty', 'Noch keine Threads — beginne einen.'));
|
||||||
|
threads.forEach((t) => {
|
||||||
|
const a = el('a', 'dialog-thread-item');
|
||||||
|
a.href = t.kind === 'library' ? t.url : '/dialog/?thread=' + encodeURIComponent(t.key);
|
||||||
|
const tt = el('span', 'dialog-thread-title', t.title);
|
||||||
|
if (t.locked) tt.appendChild(el('span', 'dialog-lock', ' 🔒'));
|
||||||
|
const mt = el('span', 'dialog-thread-meta',
|
||||||
|
(t.author_name ? t.author_name + ' · ' : '') + t.count + (t.count === 1 ? ' Wortmeldung' : ' Wortmeldungen') + ' · ' + fmt(t.last));
|
||||||
|
a.append(tt, mt); list.appendChild(a);
|
||||||
|
});
|
||||||
|
root.appendChild(list);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNewThread(box, slug) {
|
||||||
|
box.innerHTML = '';
|
||||||
|
if (!token) { const c = el('div'); box.appendChild(c); loginInline(c, () => renderNewThread(box, slug), 'Anmelden, um einen Thread zu starten'); return; }
|
||||||
|
let open = false;
|
||||||
|
function paint() {
|
||||||
|
box.innerHTML = '';
|
||||||
|
if (!open) {
|
||||||
|
const b = el('button', 'dialog-newbtn', '+ Neuer Thread');
|
||||||
|
b.onclick = () => { open = true; paint(); };
|
||||||
|
box.appendChild(b);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ti = el('input', 'dialog-input'); ti.placeholder = 'Titel des Threads';
|
||||||
|
const ta = el('textarea', 'dialog-textarea'); ta.placeholder = 'Erster Beitrag …';
|
||||||
|
const row = el('div', 'dialog-row');
|
||||||
|
const send = el('button', 'dialog-send', 'Thread starten');
|
||||||
|
const cancel = el('button', 'dialog-loginlink', 'Abbrechen'); cancel.onclick = () => { open = false; paint(); };
|
||||||
|
send.onclick = async () => {
|
||||||
|
if (!ti.value.trim() || !ta.value.trim()) return;
|
||||||
|
const r = await api('/api/threads', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHdr() }, body: JSON.stringify({ forum_slug: slug, title: ti.value, body: ta.value }) });
|
||||||
|
if (r.status === 401) { logout(); alert('Sitzung abgelaufen — bitte neu anmelden.'); paint(); return; }
|
||||||
|
if (!r.ok) { alert(r.body.error || 'Konnte Thread nicht anlegen'); return; }
|
||||||
|
location.href = '/dialog/?thread=' + encodeURIComponent(r.body.key);
|
||||||
|
};
|
||||||
|
row.append(send, cancel); box.append(ti, ta, row); ti.focus();
|
||||||
|
}
|
||||||
|
paint();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Thread-Ansicht: Wortmeldungen ─────────────────────────────────────────
|
||||||
|
function renderThread(key) {
|
||||||
|
root.innerHTML = '';
|
||||||
|
const title = el('h2', 'dialog-title', 'Dialog');
|
||||||
|
const modbar = el('div', 'dialog-modbar');
|
||||||
|
const list = el('div', 'dialog-list');
|
||||||
|
const composer = el('div', 'dialog-composer');
|
||||||
|
root.append(title, modbar, list, composer);
|
||||||
|
let replyTo = null, textarea = null, locked = false;
|
||||||
|
|
||||||
|
// Kontext: Rücklink + Titel.
|
||||||
|
api('/api/thread?key=' + encodeURIComponent(key)).then((r) => {
|
||||||
|
if (!r.ok) return;
|
||||||
|
const m = r.body; title.textContent = m.title || 'Dialog'; locked = m.locked;
|
||||||
|
if (ctxEl) {
|
||||||
|
ctxEl.innerHTML = '';
|
||||||
|
const back = el('a', null, m.kind === 'library' ? '← zum Beitrag' : (m.forum ? '← ' + m.forum.name : '← Dialoge'));
|
||||||
|
back.href = m.kind === 'library' ? m.url : (m.forum ? '/dialog/?forum=' + encodeURIComponent(m.forum.slug) : '/dialog/');
|
||||||
|
ctxEl.appendChild(back);
|
||||||
|
}
|
||||||
|
renderModbar(); renderComposer();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderModbar() {
|
||||||
|
modbar.innerHTML = '';
|
||||||
|
if (!canModerate()) return;
|
||||||
|
const lock = el('button', 'dialog-modbtn', locked ? 'Entsperren' : 'Sperren');
|
||||||
|
lock.onclick = async () => {
|
||||||
|
await api('/api/mod/thread-lock', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHdr() }, body: JSON.stringify({ key, locked: !locked }) });
|
||||||
|
locked = !locked; renderModbar(); renderComposer();
|
||||||
|
};
|
||||||
|
const del = el('button', 'dialog-modbtn', 'Thread ausblenden');
|
||||||
|
del.onclick = async () => {
|
||||||
|
if (!confirm('Thread ausblenden?')) return;
|
||||||
|
await api('/api/mod/thread-delete', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHdr() }, body: JSON.stringify({ key }) });
|
||||||
|
location.href = '/dialog/';
|
||||||
|
};
|
||||||
|
modbar.append(el('span', 'dialog-modlabel', 'Moderation:'), lock, del);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastSig = '';
|
||||||
|
async function load() {
|
||||||
|
const r = await api('/api/comments?thread=' + encodeURIComponent(key));
|
||||||
|
if (!r.ok) return;
|
||||||
|
const data = r.body;
|
||||||
|
const sig = (token ? 'in:' : 'out:') + (canModerate() ? 'm' : '') + data.map((c) => c.id + (c.deleted ? 'd' : '')).join(',');
|
||||||
|
if (sig !== lastSig) { lastSig = sig; render(data); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
list.innerHTML = '';
|
||||||
|
const names = {}; data.forEach((c) => { names[c.id] = c.author_name; });
|
||||||
|
if (!data.length) { list.appendChild(el('p', 'dialog-empty', 'Noch keine Wortmeldungen — beginne den Dialog.')); return; }
|
||||||
|
data.forEach((c) => {
|
||||||
|
const card = el('article', 'dialog-card');
|
||||||
|
const head = el('header', 'dialog-card-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();
|
||||||
|
const meta = el('div', 'dialog-meta');
|
||||||
|
meta.append(el('span', 'dialog-name', c.author_name || 'Unbekannt'), el('time', 'dialog-time', fmt(c.created_at)));
|
||||||
|
if (c.parent_id && names[c.parent_id]) meta.appendChild(el('span', 'dialog-replyto', '↳ ' + names[c.parent_id]));
|
||||||
|
head.append(av, meta); card.appendChild(head);
|
||||||
|
card.appendChild(el('div', 'dialog-body', c.body));
|
||||||
|
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); }
|
||||||
|
const del = el('button', null, 'Löschen'); del.onclick = () => remove(c.id); actions.appendChild(del);
|
||||||
|
card.appendChild(actions);
|
||||||
|
}
|
||||||
|
list.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComposer() {
|
||||||
|
composer.innerHTML = '';
|
||||||
|
// Gesperrt: niemand schreibt (auch Moderation nicht — erst entsperren).
|
||||||
|
if (locked) {
|
||||||
|
composer.appendChild(el('p', 'dialog-locked', canModerate()
|
||||||
|
? '🔒 Gesperrt — zum Schreiben oben entsperren.'
|
||||||
|
: '🔒 Dieser Thread ist gesperrt.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!token) { loginInline(composer, () => { renderModbar(); renderComposer(); load(); }); return; }
|
||||||
if (replyTo) {
|
if (replyTo) {
|
||||||
const r = document.createElement('button'); r.className = 'dialog-replychip';
|
const r = el('button', 'dialog-replychip', 'Antwort auf ' + replyTo.name + ' ✕');
|
||||||
r.textContent = 'Antwort auf ' + replyTo.name + ' ✕';
|
|
||||||
r.onclick = () => { replyTo = null; renderComposer(); };
|
r.onclick = () => { replyTo = null; renderComposer(); };
|
||||||
composer.appendChild(r);
|
composer.appendChild(r);
|
||||||
}
|
}
|
||||||
textarea = document.createElement('textarea'); textarea.className = 'dialog-textarea';
|
textarea = el('textarea', 'dialog-textarea'); textarea.placeholder = locked ? 'Thread gesperrt — nur Moderation …' : 'Deine Wortmeldung …';
|
||||||
textarea.placeholder = 'Deine Wortmeldung …';
|
const row = el('div', 'dialog-row');
|
||||||
const row = document.createElement('div'); row.className = 'dialog-row';
|
const send = el('button', 'dialog-send', 'Senden'); send.onclick = submit;
|
||||||
const send = document.createElement('button'); send.className = 'dialog-send'; send.textContent = 'Senden'; send.onclick = submit;
|
const out = el('button', 'dialog-logout', 'Abmelden' + (myName ? ' · ' + myName : '')); out.onclick = () => logout(() => { renderModbar(); renderComposer(); load(); });
|
||||||
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);
|
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) {
|
async function submit() {
|
||||||
try {
|
const body = textarea.value.trim(); if (!body) return;
|
||||||
const res = await fetch('/api/auth/login', {
|
const r = await api('/api/comments', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHdr() }, body: JSON.stringify({ thread: key, body, parent_id: replyTo ? replyTo.id : null }) });
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
if (r.status === 401) { logout(); alert('Sitzung abgelaufen — bitte neu anmelden.'); renderComposer(); return; }
|
||||||
body: JSON.stringify({ email, password }),
|
if (!r.ok) { alert(r.body.error || 'Senden fehlgeschlagen'); return; }
|
||||||
});
|
textarea.value = ''; replyTo = null; renderComposer(); load();
|
||||||
const j = await res.json().catch(() => ({}));
|
}
|
||||||
if (!res.ok) { alert(j.error || 'Login fehlgeschlagen'); return; }
|
async function remove(id) {
|
||||||
token = j.access_token; myName = j.name || '';
|
if (!confirm('Wortmeldung löschen?')) return;
|
||||||
localStorage.setItem(TKEY, token); localStorage.setItem(NKEY, myName);
|
const r = await api('/api/comments/' + id, { method: 'DELETE', headers: authHdr() });
|
||||||
renderComposer(); load();
|
if (!r.ok) { alert(r.body.error || 'Löschen fehlgeschlagen'); return; }
|
||||||
} catch { alert('Login fehlgeschlagen'); }
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
load();
|
||||||
|
setInterval(() => { if (!document.hidden) load(); }, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderComposer();
|
// ── Router ────────────────────────────────────────────────────────────────
|
||||||
load();
|
if (threadKey) renderThread(threadKey);
|
||||||
// Live genug: alle 10 s nachladen (pausiert, wenn der Tab im Hintergrund ist).
|
else if (forumSlug) renderForum(forumSlug);
|
||||||
setInterval(() => { if (!document.hidden) load(); }, 10000);
|
else renderOverview();
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user