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
…
;
if (!session) return ;
return ;
}
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 (
);
}
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 (
OPENBUREAU
Redaktion
{email}
{view === 'overview' ? (
) : view === 'profile' ? (
) : view === 'users' ? (
) : view === 'forums' ? (
) : view === 'moderation' ? (
) : (
<>
{current
? { setCurrent(loaded); refresh(); }} onMsg={setMsg} />
: Wähle links einen Eintrag — oder leg einen neuen Beitrag an.
}
>
)}
{msg &&
setMsg(null)}>{msg.text}
}
);
}
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 (
{f.isNew ? 'Neuer Eintrag' : f.path}
{f.draft ?
Entwurf :
Veröffentlicht}
{showPreview &&
}
{showPreview && (
{previewUrl
?
:
Auf „Vorschau“ klicken — die Seite erscheint hier in deinem echten Theme.
}
)}
);
}
// ── 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 ;
}
// ── 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 …
;
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 (
);
}
// ── Ü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 …
;
const Card = ({ label, value, hint, to }) => (
);
return (
);
}
// ── 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 …
;
const filtered = q ? list.filter((u) => u.email.toLowerCase().includes(q.toLowerCase())) : list;
const RoleSelect = ({ u }) => (
);
return (
Autor:innen & Rollen {list.length}
{list.length > 6 &&
setQ(e.target.value)} />}
User schreiben im Forum · Redakteur moderiert · Admin verwaltet alles. Admins aus ADMIN_EMAILS sind fix.
);
}
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 …
;
return (
Foren / Kategorien
„Beiträge“ ist die automatische Library-Kategorie und kann nicht gelöscht werden.
);
}
// ── 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 …
;
return (
);
}
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;
}