Squashed 'cms/core/' content from commit 2f52ec7

git-subtree-dir: cms/core
git-subtree-split: 2f52ec7536ee87b4f097d169e1cb0f6a85aebe84
This commit is contained in:
2026-06-30 00:09:44 +02:00
commit 80eca1bc7c
53 changed files with 6896 additions and 0 deletions
+714
View File
@@ -0,0 +1,714 @@
import { useEffect, useRef, useState } from 'react';
import ToastEditor from '@toast-ui/editor';
import '@toast-ui/editor/dist/toastui-editor.css';
import { supabase } from './supabase.js';
import { api } from './api.js';
// OPENBUREAU-Palette (Hex aus assets/css/custom.css) — für Dropdown + Punkte.
const COLORS = [
['', 'keine', 'transparent'],
['ajisai', 'Ajisai · Hortensie', '#A39EC4'],
['sakura', 'Sakura · Kirschblüte', '#C49EC4'],
['suna', 'Suna · Sand', '#C4C19E'],
['ichigo', 'Ichigo · Erdbeere', '#C49EA0'],
['yuyake', 'Yuyake · Sonnenuntergang', '#CEB188'],
['sora', 'Sora · Himmel', '#9EC3C4'],
['kusa', 'Kusa · Gras', '#9EC49F'],
['kori', 'Kori · Eis', '#A5B4CB'],
['amagumo', 'Amagumo · Regenwolke', '#4C4C4C'],
['yuki', 'Yuki · Schnee', '#F0F0F0'],
];
const hexOf = (name) => (COLORS.find((c) => c[0] === name) || [])[2] || 'transparent';
const LAYOUTS = ['', 'text', 'image', 'icon'];
const SECTIONS = ['buerofuehrung', 'software', 'theorie'];
const KIND_LABEL = { beitrag: 'Beiträge', biblio: 'Library', seite: 'Seiten', rubrik: 'Rubriken' };
const EMPTY = {
isNew: true, path: '', type: 'beitrag', section: 'software', slug: '',
title: '', date: new Date().toISOString().slice(0, 10), weight: '',
color: '', layout: 'text', tags: '', summary: '', description: '',
cover_image: '', external: '', authors: '', group: '', toc: false, draft: true, body: '',
};
export default function App() {
const [session, setSession] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
supabase.auth.getSession().then(({ data }) => { setSession(data.session); setLoading(false); });
const { data: sub } = supabase.auth.onAuthStateChange((_e, s) => setSession(s));
return () => sub.subscription.unsubscribe();
}, []);
if (loading) return <div className="center muted"></div>;
if (!session) return <Login />;
return <Dashboard email={session.user.email} />;
}
function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState(null);
async function submit(e) {
e.preventDefault(); setErr(null);
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) setErr(error.message);
}
return (
<div className="center">
<form className="login" onSubmit={submit}>
<div className="login-brand">OPENBUREAU</div>
<div className="login-sub">Redaktion</div>
<input type="email" placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} autoFocus />
<input type="password" placeholder="Passwort" value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit">Anmelden</button>
{err && <p className="err">{err}</p>}
</form>
</div>
);
}
function Dashboard({ email }) {
const [entries, setEntries] = useState([]);
const [current, setCurrent] = useState(null);
const [query, setQuery] = useState('');
const [view, setView] = useState('content');
const [me, setMe] = useState(null);
const [msg, setMsg] = useState(null);
async function refresh() {
try { setEntries(await api.list()); }
catch (e) { setMsg({ type: 'err', text: e.message }); }
}
useEffect(() => { refresh(); api.getMe().then(setMe).catch(() => {}); }, []);
useEffect(() => { if (!msg) return; const t = setTimeout(() => setMsg(null), 4000); return () => clearTimeout(t); }, [msg]);
async function open(entry) {
try { setCurrent(fromRead(await api.read(entry.path))); }
catch (err) { setMsg({ type: 'err', text: err.message }); }
}
const q = query.trim().toLowerCase();
const filtered = q ? entries.filter((e) => e.title.toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
const groups = { beitrag: [], biblio: [], seite: [], rubrik: [] };
for (const e of filtered) (groups[e.kind] || groups.seite).push(e);
return (
<div className="app">
<header className="topbar">
<span className="logo">OPENBUREAU</span>
<span className="logo-sub">Redaktion</span>
<nav className="nav">
{me?.isAdmin && <button className={view === 'overview' ? 'active' : ''} onClick={() => setView('overview')}>Übersicht</button>}
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</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>}
</nav>
<span className="spacer" />
<span className="who">{email}</span>
<button className="ghost" onClick={() => supabase.auth.signOut()}>Abmelden</button>
</header>
<div className="body">
{view === 'overview' ? (
<Overview onMsg={setMsg} go={setView} />
) : view === 'profile' ? (
<Profile onMsg={setMsg} />
) : view === 'users' ? (
<Users onMsg={setMsg} currentEmail={me?.email} />
) : view === 'forums' ? (
<Forums onMsg={setMsg} />
) : view === 'moderation' ? (
<Moderation onMsg={setMsg} />
) : (
<>
<aside>
<button className="new" onClick={() => setCurrent({ ...EMPTY })}> Neuer Beitrag</button>
<div className="search"><span></span><input placeholder="Suchen…" value={query} onChange={(e) => setQuery(e.target.value)} /></div>
{['beitrag', 'biblio', 'seite', 'rubrik'].map((kind) => groups[kind].length > 0 && (
<div className="group" key={kind}>
<div className="group-title">{KIND_LABEL[kind]} <span>{groups[kind].length}</span></div>
<ul className="list">
{groups[kind].map((e) => (
<li key={e.path} className={current?.path === e.path ? 'active' : ''} onClick={() => open(e)}>
<span className="dot" style={{ background: e.color ? hexOf(e.color) : 'var(--line)' }} />
<span className="t">
<span className="t-title">{e.title}</span>
<span className="t-meta">{[e.section, e.date].filter(Boolean).join(' · ')}</span>
</span>
{e.draft && <span className="draft-tag">Entwurf</span>}
</li>
))}
</ul>
</div>
))}
</aside>
<main>
{current
? <Editor key={current.path || 'new'} initial={current}
onSaved={(loaded) => { setCurrent(loaded); refresh(); }} onMsg={setMsg} />
: <div className="empty"><p>Wähle links einen Eintrag oder leg einen neuen Beitrag an.</p></div>}
</main>
</>
)}
</div>
{msg && <div className={`toast ${msg.type}`} onClick={() => setMsg(null)}>{msg.text}</div>}
</div>
);
}
function Editor({ initial, onSaved, onMsg }) {
const [f, setF] = useState(initial);
const [previewUrl, setPreviewUrl] = useState(null);
const [showPreview, setShowPreview] = useState(false);
const [pw, setPw] = useState(44);
const [busy, setBusy] = useState(false);
const editorRef = useRef(null);
const dragging = useRef(false);
const coverIn = useRef(null);
const set = (k) => (e) => setF({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value });
const isWiki = f.type === 'biblio' || (f.path || '').startsWith('library/');
async function pickCover(ev) {
const file = ev.target.files?.[0]; ev.target.value = '';
if (!file) return;
setBusy(true);
try { const { url } = await api.upload(file); setF((p) => ({ ...p, cover_image: url })); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
finally { setBusy(false); }
}
// Ziehbarer Trenner Editor ↔ Vorschau.
useEffect(() => {
function move(e) {
if (!dragging.current || !editorRef.current) return;
const r = editorRef.current.getBoundingClientRect();
setPw(Math.min(70, Math.max(25, ((r.right - e.clientX) / r.width) * 100)));
}
function up() { dragging.current = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; }
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
}, []);
function startDrag(e) { dragging.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }
function currentPath(data = f) {
if (!data.isNew) return data.path;
const slug = (data.slug || '').trim();
if (!slug) return '';
if (data.type === 'beitrag') return `archiv/${data.section}/${slug}.md`;
if (data.type === 'biblio') return `library/${slug}.md`;
return `${slug}.md`;
}
// overrides erlauben z.B. { draft: false } beim Publizieren.
async function save(overrides = {}) {
const data = { ...f, ...overrides };
const path = currentPath(data);
if (!path) { onMsg({ type: 'err', text: 'Bitte einen Slug angeben.' }); return null; }
if (!data.title.trim()) { onMsg({ type: 'err', text: 'Titel fehlt.' }); return null; }
setBusy(true);
try {
await api.save(path, buildFrontmatter(data), data.body);
const loaded = fromRead(await api.read(path));
onSaved(loaded); setF(loaded);
return path;
} catch (e) { onMsg({ type: 'err', text: e.message }); return null; }
finally { setBusy(false); }
}
async function preview() {
const path = await save(); if (!path) return;
setShowPreview(true); setBusy(true);
try { const res = await api.preview(path); setPreviewUrl(`${res.url}?t=${Date.now()}`); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
finally { setBusy(false); }
}
async function publish() {
if (!confirm('Live publizieren? Der Beitrag wird aus „Entwurf“ genommen.')) return;
const path = await save({ draft: false }); // Publizieren = nicht mehr Entwurf
if (!path) return;
setBusy(true);
try { const res = await api.publish(path); onMsg({ type: 'ok', text: `Live: ${res.url}` }); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
finally { setBusy(false); }
}
return (
<div className="editor" ref={editorRef}>
<div className="editor-main">
<div className="editor-head">
<div className="crumb">{f.isNew ? 'Neuer Eintrag' : f.path}</div>
<span className="spacer" />
{f.draft ? <span className="status draft">Entwurf</span> : <span className="status live">Veröffentlicht</span>}
<button className="toggle" onClick={() => setShowPreview((v) => !v)} title="Vorschau ein/aus">
{showPreview ? 'Vorschau ' : 'Vorschau ⤢'}
</button>
<button onClick={() => save().then((p) => p && onMsg({ type: 'ok', text: 'Gespeichert.' }))} disabled={busy}>Speichern</button>
<button onClick={preview} disabled={busy}>Vorschau</button>
<button className="primary" onClick={publish} disabled={busy}>Publizieren</button>
</div>
<div className="fields">
{f.isNew && (
<div className="row">
<label className="sm">Typ
<select value={f.type} onChange={set('type')}>
<option value="beitrag">Beitrag</option>
<option value="biblio">Library-Seite</option>
<option value="seite">Seite</option>
</select>
</label>
{f.type === 'beitrag' && (
<label className="sm">Rubrik
<select value={f.section} onChange={set('section')}>{SECTIONS.map((s) => <option key={s}>{s}</option>)}</select>
</label>
)}
<label>Slug<input value={f.slug} onChange={set('slug')} placeholder="z.B. neuer-beitrag" /></label>
</div>
)}
<label className="big">Titel<input value={f.title} onChange={set('title')} placeholder="Titel des Beitrags" /></label>
<div className="meta">
{isWiki && <label className="sm">Gruppe<input value={f.group} onChange={set('group')} placeholder="z. B. Begriffe" /></label>}
<label className="sm">Datum<input type="date" value={f.date} onChange={set('date')} /></label>
<label className="xs">Reihenfolge<input type="number" value={f.weight} onChange={set('weight')} placeholder="weight" /></label>
<label className="sm">Farbe
<div className="colorpick">
<span className="swatch" style={{ background: hexOf(f.color) }} />
<select value={f.color} onChange={set('color')}>{COLORS.map(([v, label]) => <option key={v} value={v}>{label}</option>)}</select>
</div>
</label>
<label className="sm">Layout
<select value={f.layout} onChange={set('layout')}>{LAYOUTS.map((l) => <option key={l} value={l}>{l || '(automatisch)'}</option>)}</select>
</label>
<label>Tags<input value={f.tags} onChange={set('tags')} placeholder="komma, getrennt" /></label>
<label className="check"><input type="checkbox" checked={f.toc} onChange={set('toc')} /> Inhaltsverz.</label>
<label className="check"><input type="checkbox" checked={f.draft} onChange={set('draft')} /> Entwurf</label>
</div>
<label>Kurztext (summary)<input value={f.summary} onChange={set('summary')} /></label>
<div className="row">
<label>Cover-Bild
<div className="cover-row">
<input value={f.cover_image} onChange={set('cover_image')} placeholder="/images/…jpg" />
<button type="button" onClick={() => coverIn.current?.click()} disabled={busy}>Hochladen</button>
<input ref={coverIn} type="file" accept="image/*" hidden onChange={pickCover} />
{f.cover_image && <span className="cover-thumb" style={{ backgroundImage: `url(${f.cover_image})` }} />}
</div>
</label>
<label>Externer Link<input value={f.external} onChange={set('external')} placeholder="https://…" /></label>
</div>
<label>Autor:innen (E-Mails, Komma für gemeinsamen Zugriff)
<input value={f.authors} onChange={set('authors')} placeholder="du@…, kollege@…" />
</label>
<div className="rich">
<RichEditor value={f.body} onChange={(body) => setF((p) => ({ ...p, body }))}
onUpload={async (file) => (await api.upload(file)).url} />
</div>
</div>
</div>
{showPreview && <div className="splitter" onMouseDown={startDrag} />}
{showPreview && (
<div className="preview" style={{ width: pw + '%' }}>
{previewUrl
? <iframe title="Vorschau" src={previewUrl} />
: <div className="empty small"><p>Auf Vorschau klicken die Seite erscheint hier in deinem echten Theme.</p></div>}
</div>
)}
</div>
);
}
// ── WYSIWYG-Editor (Toast UI, vanilla) — Formatierung live, speichert Markdown ──
function RichEditor({ value, onChange, onUpload }) {
const el = useRef(null);
const inst = useRef(null);
// value/onChange/onUpload in Refs, damit der Editor nur EINMAL erzeugt wird.
const cb = useRef({ onChange, onUpload });
cb.current = { onChange, onUpload };
useEffect(() => {
inst.current = new ToastEditor({
el: el.current,
initialValue: value || '',
initialEditType: 'wysiwyg',
previewStyle: 'tab',
height: '100%',
usageStatistics: false,
autofocus: false,
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol'],
['link', 'image'],
['code', 'codeblock'],
],
hooks: {
addImageBlobHook: async (blob, done) => {
try { done(await cb.current.onUpload(blob), blob.name || 'bild'); }
catch { /* Upload fehlgeschlagen */ }
},
},
events: { change: () => cb.current.onChange(inst.current.getMarkdown()) },
});
return () => { inst.current?.destroy(); inst.current = null; };
}, []);
return <div ref={el} className="rich-host" />;
}
// ── Profil ──────────────────────────────────────────────────────────────────
function Profile({ onMsg }) {
const [p, setP] = useState(null);
const [busy, setBusy] = useState(false);
const fileIn = useRef(null);
useEffect(() => { api.getProfile().then(setP).catch((e) => onMsg({ type: 'err', text: e.message })); }, []);
if (!p) return <div className="empty"></div>;
const set = (k) => (e) => setP({ ...p, [k]: e.target.value });
async function pickAvatar(ev) {
const file = ev.target.files?.[0]; ev.target.value = '';
if (!file) return;
setBusy(true);
try { const { url } = await api.upload(file); setP((x) => ({ ...x, avatar: url })); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
finally { setBusy(false); }
}
async function save() {
setBusy(true);
try { await api.saveProfile({ name: p.name, bio: p.bio, avatar: p.avatar }); onMsg({ type: 'ok', text: 'Profil gespeichert.' }); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
finally { setBusy(false); }
}
return (
<div className="profile">
<div className="profile-card">
<h2>Profil</h2>
<div className="avatar-row">
<div className="avatar" style={{ backgroundImage: p.avatar ? `url(${p.avatar})` : 'none' }}>{!p.avatar && '🙂'}</div>
<div>
<button onClick={() => fileIn.current?.click()} disabled={busy}>Profilbild wählen</button>
<input ref={fileIn} type="file" accept="image/*" hidden onChange={pickAvatar} />
<p className="muted who-mail">{p.email}</p>
</div>
</div>
<label>Name<input value={p.name} onChange={set('name')} placeholder="Dein Name" /></label>
<label>Über mich<textarea value={p.bio} onChange={set('bio')} rows={5} placeholder="Kurzer Text über dich…" /></label>
<div className="actions"><button className="primary" onClick={save} disabled={busy}>Speichern</button></div>
</div>
</div>
);
}
// ── Übersicht / Dashboard (nur Admin) ───────────────────────────────────────
function Overview({ onMsg, go }) {
const [s, setS] = useState(null);
useEffect(() => { api.stats().then(setS).catch((e) => onMsg({ type: 'err', text: e.message })); }, []);
if (!s) return <div className="empty"></div>;
const Card = ({ label, value, hint, to }) => (
<button className="stat-card" onClick={to ? () => go(to) : undefined} disabled={!to}>
<span className="stat-value">{value}</span>
<span className="stat-label">{label}</span>
<span className="stat-hint">{hint || ' '}</span>
</button>
);
return (
<div className="overview">
<h2>Übersicht</h2>
<div className="stat-grid">
<Card label="Beiträge" value={s.content.beitraege} hint={`${s.content.entwuerfe} Entwürfe`} to="content" />
<Card label="Library-Seiten" value={s.content.library} to="content" />
<Card label="Seiten" value={s.content.seiten} />
<Card label="Autor:innen" value={s.users.total} hint={`${s.users.admin} Admin · ${s.users.editor} Red.`} to="users" />
<Card label="Foren" value={s.dialog.forums} to="forums" />
<Card label="Threads" value={s.dialog.threads} to="moderation" />
<Card label="Wortmeldungen" value={s.dialog.comments} to="moderation" />
</div>
<div className="overview-actions">
<h3>Schnellzugriff</h3>
<div className="quick">
<button onClick={() => go('content')}>Inhalte bearbeiten</button>
<button onClick={() => go('forums')}>Foren verwalten</button>
<button onClick={() => go('users')}>Autor:innen &amp; Rollen</button>
<a className="quick-link" href="/" target="_blank" rel="noreferrer">Website </a>
<a className="quick-link" href="/dialog/" target="_blank" rel="noreferrer">Dialog </a>
<a className="quick-link" href="/library/" target="_blank" rel="noreferrer">Library </a>
</div>
</div>
</div>
);
}
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
function Users({ onMsg, currentEmail }) {
const [list, setList] = useState(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState('user');
const [busy, setBusy] = useState(false);
const [q, setQ] = useState('');
const [pwFor, setPwFor] = useState(null);
const [newPw, setNewPw] = useState('');
async function refresh() {
try { setList(await api.listUsers()); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
useEffect(() => { refresh(); }, []);
async function create(e) {
e.preventDefault(); setBusy(true);
try {
await api.createUser(email, password, role);
onMsg({ type: 'ok', text: 'Autor:in angelegt.' });
setEmail(''); setPassword(''); setRole('user'); refresh();
} catch (err) { onMsg({ type: 'err', text: err.message }); }
finally { setBusy(false); }
}
async function remove(u) {
if (!confirm(`${u.email} wirklich löschen?`)) return;
try { await api.deleteUser(u.id); refresh(); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
async function savePw(u) {
if (!newPw || newPw.length < 6) { onMsg({ type: 'err', text: 'Passwort zu kurz (min. 6 Zeichen).' }); return; }
try { await api.setPassword(u.id, newPw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); setPwFor(null); setNewPw(''); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
async function changeRole(u, r) {
try { await api.setRole(u.id, r); onMsg({ type: 'ok', text: `Rolle: ${ROLE_LABEL[r]}` }); refresh(); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
if (!list) return <div className="empty"></div>;
const filtered = q ? list.filter((u) => u.email.toLowerCase().includes(q.toLowerCase())) : list;
const RoleSelect = ({ u }) => (
<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>
);
return (
<div className="profile">
<div className="profile-card wide">
<h2>Autor:innen &amp; Rollen <span className="count-pill">{list.length}</span></h2>
<form className="userform" onSubmit={create}>
<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 />
<select className="role-select" value={role} onChange={(e) => setRole(e.target.value)}>
<option value="user">User</option><option value="editor">Redakteur</option><option value="admin">Admin</option>
</select>
<button className="primary" disabled={busy}>Anlegen</button>
</form>
{list.length > 6 && <input className="userfilter" placeholder="filtern…" value={q} onChange={(e) => setQ(e.target.value)} />}
<ul className="userlist">
{filtered.map((u) => (
<li key={u.id}>
<span className="uavatar" style={avatarStyle(u.email)}>{(u.email || '?').slice(0, 1).toUpperCase()}</span>
<span className="t ucol">
<span className="uemail">{u.email}{u.email === currentEmail && <span className="you"> · du</span>}</span>
<span className="umeta">
angelegt {fmtDate(u.created_at)}
{u.last_sign_in_at ? ` · zuletzt aktiv ${fmtDate(u.last_sign_in_at)}` : ' · nie angemeldet'}
</span>
</span>
{u.fixedAdmin ? <span className="rolebadge admin">Admin · .env</span> : <RoleSelect u={u} />}
{pwFor === u.id ? (
<span className="pwinline">
<input type="text" placeholder="neues Passwort" value={newPw} autoFocus
onChange={(e) => setNewPw(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') savePw(u); if (e.key === 'Escape') { setPwFor(null); setNewPw(''); } }} />
<button onClick={() => savePw(u)}>OK</button>
<button onClick={() => { setPwFor(null); setNewPw(''); }}></button>
</span>
) : (
<button onClick={() => { setPwFor(u.id); setNewPw(''); }}>Passwort</button>
)}
{u.email !== currentEmail && !u.fixedAdmin && <button onClick={() => remove(u)}>Löschen</button>}
</li>
))}
</ul>
<p className="muted who-mail"><b>User</b> schreiben im Forum · <b>Redakteur</b> moderiert · <b>Admin</b> verwaltet alles. Admins aus <code>ADMIN_EMAILS</code> sind fix.</p>
</div>
</div>
);
}
const ROLE_LABEL = { user: 'User', editor: 'Redakteur', admin: 'Admin' };
function fmtDate(ts) { if (!ts) return '—'; try { return new Date(ts).toLocaleDateString('de-CH'); } catch { return '—'; } }
function uHashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return Math.abs(h) % 360; }
function avatarStyle(s) { const h = uHashHue(s); return { background: `hsl(${h} 36% 82%)`, color: `hsl(${h} 30% 28%)` }; }
// ── 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 ────────────────────────────────────────
function fromRead(r) {
const fm = r.frontmatter || {};
const p = r.path || '';
const type = p.startsWith('archiv/') ? 'beitrag' : p.startsWith('library/') ? 'biblio' : 'seite';
return {
isNew: false, path: r.path, type, section: '', slug: '',
title: fm.title || '', date: fm.date ? String(fm.date).slice(0, 10) : '',
weight: fm.weight ?? '', color: fm.color || '', layout: fm.layout || '',
tags: Array.isArray(fm.tags) ? fm.tags.join(', ') : '',
summary: fm.summary || '', description: fm.description || '',
cover_image: fm.cover_image || '', external: fm.external || '',
authors: Array.isArray(fm.authors) ? fm.authors.join(', ') : (fm.authors || ''),
group: fm.group || '', toc: !!fm.toc, draft: !!fm.draft, body: r.body || '',
};
}
function buildFrontmatter(f) {
const fm = { title: f.title };
if (f.date) fm.date = f.date;
if (f.weight !== '' && f.weight != null) fm.weight = Number(f.weight);
const tags = f.tags ? f.tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
if (tags.length) fm.tags = tags;
if (f.summary) fm.summary = f.summary;
if (f.description) fm.description = f.description;
if (f.cover_image) fm.cover_image = f.cover_image;
if (f.layout) fm.layout = f.layout;
if (f.external) fm.external = f.external;
if (f.color) fm.color = f.color;
const authors = f.authors ? f.authors.split(',').map((t) => t.trim()).filter(Boolean) : [];
if (authors.length) fm.authors = authors;
if (f.group) fm.group = f.group;
if (f.toc) fm.toc = true;
if (f.draft) fm.draft = true;
return fm;
}
+65
View File
@@ -0,0 +1,65 @@
import { supabase } from './supabase.js';
// Ruft die CMS-API (gleiche Origin) mit dem aktuellen Supabase-Token auf.
async function call(path, options = {}) {
const { data } = await supabase.auth.getSession();
const token = data?.session?.access_token;
const res = await fetch(`/api${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
return json;
}
// Datei-Upload (multipart): Browser setzt den Header selbst.
async function uploadFile(file) {
const { data } = await supabase.auth.getSession();
const token = data?.session?.access_token;
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/upload', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
return json;
}
export const api = {
list: () => call('/content'),
read: (path) => call(`/content/entry?path=${encodeURIComponent(path)}`),
save: (path, frontmatter, body) =>
call('/content/entry', { method: 'PUT', body: JSON.stringify({ path, frontmatter, body }) }),
preview: (path) => call('/preview', { method: 'POST', body: JSON.stringify({ path }) }),
publish: (path) => call('/publish', { method: 'POST', body: JSON.stringify({ path }) }),
upload: uploadFile,
getProfile: () => call('/profile'),
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
getMe: () => call('/me'),
stats: () => call('/stats'),
listUsers: () => call('/users'),
createUser: (email, password, role) => call('/users', { method: 'POST', body: JSON.stringify({ email, password, role }) }),
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' }),
// 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' }),
};
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+221
View File
@@ -0,0 +1,221 @@
@import url('https://fonts.bunny.net/css?family=newsreader:400,500,600,700|inter:400,500,600|space-grotesk:500,700|ibm-plex-mono:400,500');
:root {
--serif: 'Newsreader', Georgia, serif;
--sans: 'Inter', system-ui, -apple-system, sans-serif;
--display: 'Space Grotesk', 'Inter', sans-serif;
--mono: 'IBM Plex Mono', ui-monospace, monospace;
--bg: hsl(35 14% 96%);
--panel: #fffdf9;
--panel-2: hsl(35 14% 93%);
--line: hsl(35 14% 86%);
--text: hsl(25 18% 12%);
--muted: hsl(25 8% 42%);
--accent: #b54a2c;
--accent-soft: #d97a5a;
--dark: #191919;
--dark-text: #f0f0f0;
--dark-muted: #a9a9a9;
--ok: #5d7d4b;
--amber: #b8902f;
--radius: 11px;
--pill: 22px;
--shadow: 0 10px 34px -22px rgba(40,20,10,.5);
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body { margin: 0; font-family: var(--sans); font-size: 14.5px; color: var(--text); background: var(--bg); }
button, input, select, textarea { font-family: inherit; font-size: inherit; color: var(--text); }
.muted { color: var(--muted); }
.center { display: grid; place-items: center; height: 100%; }
/* ── Login ── */
.login { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 36px 32px; width: 320px; display: flex; flex-direction: column; gap: 12px; box-shadow: var(--shadow); }
.login-brand { font-family: var(--display); font-weight: 700; letter-spacing: .14em; font-size: 20px; }
.login-sub { font-family: var(--serif); font-style: italic; color: var(--muted); margin-bottom: 10px; }
.err { color: var(--accent); margin: 4px 0 0; font-size: 13px; }
/* ── Inputs / Buttons (Pill) ── */
input, select, textarea { background: var(--panel); border: 1px solid var(--line); border-radius: 9px; padding: 9px 11px; width: 100%; }
/* Einheitliche, kompakte Höhe für einzeilige Felder (Dropdowns = Textfelder) */
.fields input, .fields select { height: 32px; padding: 0 10px; font-size: 14px; }
.fields label.big input { height: 46px; padding: 0 13px; font-size: 21px; }
.login input, .profile-card input, .userform input { height: 36px; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent-soft); box-shadow: 0 0 0 3px rgba(181,74,44,.12); }
button { background: var(--panel); border: 1px solid var(--line); border-radius: var(--pill); padding: 8px 16px; cursor: pointer; font-weight: 500; transition: .12s; white-space: nowrap; }
button:hover { border-color: var(--accent-soft); }
button:disabled { opacity: .5; cursor: default; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover { background: #a23f23; }
button.ghost { background: transparent; border-color: transparent; color: var(--dark-muted); }
button.ghost:hover { color: #fff; border-color: var(--dark-muted); }
/* ── Topbar (schwarz wie Site-Masthead) ── */
.app { display: flex; flex-direction: column; height: 100%; }
.topbar { display: flex; align-items: center; gap: 12px; padding: 0 18px; height: 54px; background: var(--dark); color: var(--dark-text); flex: none; }
.topbar .logo { font-family: var(--display); font-weight: 700; letter-spacing: .14em; }
.topbar .logo-sub { font-family: var(--serif); font-style: italic; color: var(--dark-muted); font-size: 13px; }
.topbar .spacer { flex: 1; }
.topbar .who { color: var(--dark-muted); font-size: 13px; }
.nav { display: flex; gap: 4px; margin-left: 16px; }
.nav button { background: transparent; border: none; color: var(--dark-muted); padding: 6px 15px; border-radius: var(--pill); }
.nav button:hover { color: #fff; }
.nav button.active { background: rgba(255,255,255,.12); color: #fff; }
.body { display: flex; flex: 1; min-height: 0; }
/* ── Sidebar ── */
aside { width: 290px; flex: none; border-right: 1px solid var(--line); background: var(--panel-2); padding: 14px; overflow: auto; }
.new { width: 100%; margin-bottom: 12px; background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
.new:hover { background: #a23f23; }
.search { display: flex; align-items: center; gap: 7px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--pill); padding: 0 13px; margin-bottom: 16px; }
.search span { color: var(--muted); font-size: 17px; }
.search input { border: none; background: transparent; padding: 9px 0; }
.search input:focus { box-shadow: none; }
.group { margin-bottom: 18px; }
.group-title { display: flex; align-items: center; gap: 7px; font-family: var(--display); font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin: 0 6px 8px; }
.group-title span { background: var(--line); color: var(--muted); border-radius: 20px; padding: 1px 7px; font-size: 10px; letter-spacing: 0; }
.list { list-style: none; margin: 0; padding: 0; }
.list li { display: flex; align-items: center; gap: 10px; padding: 9px; border-radius: 10px; cursor: pointer; }
.list li:hover { background: var(--panel); }
.list li.active { background: var(--panel); box-shadow: inset 3px 0 0 var(--accent), var(--shadow); }
.list .dot { width: 10px; height: 10px; border-radius: 50%; flex: none; border: 1px solid rgba(0,0,0,.12); }
.list .t { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.list .t-title { font-family: var(--serif); font-size: 15.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.list .t-meta { font-size: 11px; color: var(--muted); }
.draft-tag { font-size: 10px; color: var(--amber); border: 1px solid var(--amber); border-radius: 20px; padding: 1px 7px; flex: none; }
main { flex: 1; min-width: 0; }
.empty { display: grid; place-items: center; height: 100%; color: var(--muted); font-family: var(--serif); font-style: italic; padding: 24px; text-align: center; }
.empty.small { font-size: 14px; }
/* ── Editor ── */
.editor { display: flex; height: 100%; }
.editor-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.editor-head { display: flex; align-items: center; gap: 9px; padding: 11px 22px; border-bottom: 1px solid var(--line); background: var(--panel); flex: none; }
.editor-head .crumb { font-family: var(--mono); font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.editor-head .spacer { flex: 1; }
.editor-head .toggle { background: transparent; border-color: transparent; color: var(--muted); }
.editor-head .toggle:hover { color: var(--text); border-color: var(--line); }
.status { font-size: 11px; border-radius: var(--pill); padding: 3px 11px; font-weight: 600; }
.status.draft { color: var(--amber); background: rgba(184,144,47,.12); }
.status.live { color: var(--ok); background: rgba(93,125,75,.14); }
/* Metadaten kompakt oben, Schreibfeld groß darunter */
.fields { flex: 1; min-height: 0; padding: 16px 22px; overflow: auto; display: flex; flex-direction: column; gap: 10px; }
.row { display: flex; gap: 12px; align-items: flex-end; }
.meta { display: flex; flex-wrap: wrap; gap: 9px 12px; align-items: flex-end; }
label { display: flex; flex-direction: column; gap: 3px; font-size: 11.5px; color: var(--muted); flex: 1; }
.meta label { flex: 0 0 auto; }
.meta label.sm { width: 160px; } .meta label.xs { width: 100px; } .meta label:not(.sm):not(.xs):not(.check) { flex: 1; min-width: 140px; }
label.check { flex-direction: row; align-items: center; gap: 7px; white-space: nowrap; padding-bottom: 7px; }
label.check input { width: auto; height: auto; }
label.big input { font-family: var(--serif); font-weight: 600; }
.colorpick { display: flex; align-items: center; gap: 8px; }
.colorpick .swatch { width: 32px; height: 32px; border-radius: 7px; border: 1px solid rgba(0,0,0,.15); flex: none; }
.colorpick select { flex: 1; }
/* Cover-Upload */
.cover-row { display: flex; align-items: center; gap: 8px; }
.cover-row input { flex: 1; }
.cover-row button { height: 32px; flex: none; padding: 0 14px; }
.cover-thumb { width: 32px; height: 32px; border-radius: 7px; border: 1px solid var(--line); background: center/cover no-repeat; flex: none; }
/* Autor:innen-Verwaltung */
.userform { display: flex; gap: 8px; }
.userform input { flex: 1; }
.userlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.userlist li { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 10px; }
.userlist .t { flex: 1; display: flex; align-items: center; gap: 9px; font-family: var(--serif); }
.userlist button { padding: 5px 12px; font-size: 13px; }
.userlist .status { padding: 2px 9px; }
/* WYSIWYG-Editor füllt den meisten Platz */
.rich { flex: 1; min-height: 460px; display: flex; flex-direction: column; }
.rich-host { flex: 1; min-height: 0; }
.rich .toastui-editor-defaultUI { height: 100%; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); font-family: var(--sans); }
.toastui-editor-contents { font-family: var(--serif); font-size: 16px; }
.toastui-editor-defaultUI-toolbar { background: var(--panel-2); }
.toastui-editor-toolbar { border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
/* ── Ziehbarer Trenner + Vorschau ── */
.splitter { width: 7px; flex: none; cursor: col-resize; background: var(--line); }
.splitter:hover { background: var(--accent-soft); }
.preview { flex: none; background: #fff; }
.preview iframe { width: 100%; height: 100%; border: 0; }
/* ── Profil ── */
.profile { width: 100%; overflow: auto; display: flex; justify-content: center; padding: 44px 20px; }
.profile-card { width: 100%; max-width: 560px; height: max-content; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 28px 30px; display: flex; flex-direction: column; gap: 16px; }
.profile-card h2 { font-family: var(--serif); margin: 0 0 4px; font-weight: 600; }
.avatar-row { display: flex; align-items: center; gap: 18px; }
.avatar { width: 92px; height: 92px; border-radius: 50%; background: var(--panel-2) center/cover no-repeat; border: 1px solid var(--line); display: grid; place-items: center; font-size: 34px; flex: none; }
.who-mail { font-size: 12px; margin: 9px 0 0; }
.profile-card textarea { font-family: var(--serif); font-size: 15px; line-height: 1.6; resize: vertical; }
.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; }
/* ── Übersicht / Dashboard ── */
.overview { width: 100%; overflow: auto; padding: 30px 28px; }
.overview h2 { font-family: var(--serif); font-weight: 600; margin: 0 0 18px; }
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
.stat-card { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left;
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow); }
.stat-card:not(:disabled):hover { border-color: var(--accent-soft); transform: translateY(-1px); }
.stat-card:disabled { opacity: 1; cursor: default; }
.stat-value { font-family: var(--display); font-weight: 700; font-size: 30px; line-height: 1; color: var(--accent); }
.stat-label { font-family: var(--serif); font-size: 15px; margin-top: 6px; }
.stat-hint { font-size: 11.5px; color: var(--muted); min-height: 1em; }
.overview-actions { margin-top: 30px; }
.overview-actions h3 { font-family: var(--display); font-size: 12px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin: 0 0 10px; }
.quick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.quick-link { display: inline-flex; align-items: center; padding: 8px 16px; border: 1px solid var(--line); border-radius: var(--pill); text-decoration: none; color: var(--muted); }
.quick-link:hover { border-color: var(--accent-soft); color: var(--text); }
/* ── Nutzerliste (aufgewertet) ── */
.count-pill { font-family: var(--sans); font-size: 12px; font-weight: 500; color: var(--muted); background: var(--panel-2); border-radius: 20px; padding: 2px 9px; vertical-align: middle; margin-left: 6px; }
.userfilter { margin: 4px 0 2px; height: 34px; }
.userlist .uavatar { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-weight: 600; font-size: 13px; flex: none; }
.userlist .ucol { flex-direction: column; align-items: flex-start; gap: 1px; min-width: 0; }
.uemail { font-family: var(--serif); font-size: 14.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
.uemail .you { color: var(--accent); font-family: var(--sans); font-size: 12px; }
.umeta { font-size: 11.5px; color: var(--muted); }
.rolebadge { font-size: 11px; border-radius: var(--pill); padding: 3px 10px; font-weight: 600; flex: none; }
.rolebadge.admin { color: var(--accent); background: rgba(181,74,44,.12); }
.pwinline { display: flex; align-items: center; gap: 5px; flex: none; }
.pwinline input { width: 150px; height: 30px; }
.pwinline button { padding: 4px 10px; font-size: 12.5px; }
/* ── 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.ok { background: var(--ok); }
.toast.err { background: var(--accent); }
+8
View File
@@ -0,0 +1,8 @@
import { createClient } from '@supabase/supabase-js';
// Öffentliche Browser-Werte (zur Build-Zeit von Vite eingesetzt). Der anon-Key
// ist per Design öffentlich; die echte Autorität liegt server-seitig.
const url = import.meta.env.VITE_SUPABASE_URL;
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const supabase = createClient(url, anonKey);