cms: Autoren-Verwaltung (Admin), Cover-Upload, einheitliche Feldhöhen

- Admin-only Seite „Autor:innen": Nutzer anlegen/Passwort setzen/löschen via
  GoTrue-Admin-API (/api/users, requireAdmin). /api/me liefert isAdmin → Nav
  zeigt den Punkt nur Admins.
- Cover-Bild: Upload-Knopf + Thumbnail (Bilder im Beitrag gingen schon über den
  WYSIWYG-Editor).
- Editor-Metazeile: einzeilige Felder + Dropdowns einheitlich 38px hoch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 12:40:31 +02:00
parent 10d803b7b3
commit f42a69c7ed
6 changed files with 159 additions and 3 deletions
+79 -2
View File
@@ -72,13 +72,14 @@ function Dashboard({ email }) {
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(); }, []);
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) {
@@ -99,6 +100,7 @@ function Dashboard({ email }) {
<nav className="nav">
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
{me?.isAdmin && <button className={view === 'users' ? 'active' : ''} onClick={() => setView('users')}>Autor:innen</button>}
</nav>
<span className="spacer" />
<span className="who">{email}</span>
@@ -108,6 +110,8 @@ function Dashboard({ email }) {
<div className="body">
{view === 'profile' ? (
<Profile onMsg={setMsg} />
) : view === 'users' ? (
<Users onMsg={setMsg} currentEmail={me?.email} />
) : (
<>
<aside>
@@ -154,8 +158,18 @@ function Editor({ initial, onSaved, onMsg }) {
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 });
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) {
@@ -263,7 +277,14 @@ function Editor({ initial, onSaved, onMsg }) {
<label>Kurztext (summary)<input value={f.summary} onChange={set('summary')} /></label>
<div className="row">
<label>Cover-Bild<input value={f.cover_image} onChange={set('cover_image')} placeholder="/images/…jpg" /></label>
<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)
@@ -371,6 +392,62 @@ function Profile({ onMsg }) {
);
}
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
function Users({ onMsg, currentEmail }) {
const [list, setList] = useState(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
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); onMsg({ type: 'ok', text: 'Autor:in angelegt.' }); setEmail(''); setPassword(''); refresh(); }
catch (err) { onMsg({ type: 'err', text: err.message }); }
finally { setBusy(false); }
}
async function remove(u) {
if (!confirm(`${u.email} löschen?`)) return;
try { await api.deleteUser(u.id); refresh(); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
async function reset(u) {
const pw = prompt(`Neues Passwort für ${u.email}:`);
if (!pw) return;
try { await api.setPassword(u.id, pw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
if (!list) return <div className="empty"></div>;
return (
<div className="profile">
<div className="profile-card">
<h2>Autor:innen</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 />
<button className="primary" disabled={busy}>Anlegen</button>
</form>
<ul className="userlist">
{list.map((u) => (
<li key={u.id}>
<span className="t">{u.email}{u.isAdmin && <span className="status live">Admin</span>}</span>
<button onClick={() => reset(u)}>Passwort</button>
{u.email !== currentEmail && <button onClick={() => remove(u)}>Löschen</button>}
</li>
))}
</ul>
<p className="muted who-mail">Admin-Rechte werden über <code>ADMIN_EMAILS</code> in der .env vergeben (nicht hier).</p>
</div>
</div>
);
}
// ── Mapping Datei-Lesart → Formular ────────────────────────────────────────
function fromRead(r) {
const fm = r.frontmatter || {};
+5
View File
@@ -43,4 +43,9 @@ export const api = {
upload: uploadFile,
getProfile: () => call('/profile'),
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
getMe: () => call('/me'),
listUsers: () => call('/users'),
createUser: (email, password) => call('/users', { method: 'POST', body: JSON.stringify({ email, password }) }),
setPassword: (id, password) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ password }) }),
deleteUser: (id) => call(`/users/${id}`, { method: 'DELETE' }),
};
+20 -1
View File
@@ -41,6 +41,10 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; colo
/* ── Inputs / Buttons (Pill) ── */
input, select, textarea { background: var(--panel); border: 1px solid var(--line); border-radius: 9px; padding: 9px 11px; width: 100%; }
/* Einheitliche Höhe für einzeilige Felder (Dropdowns = Textfelder) */
.fields label:not(.big) input, .fields select, .login input, .profile-card input, .userform input {
height: 38px; padding: 0 11px;
}
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); }
@@ -112,9 +116,24 @@ label.check { flex-direction: row; align-items: center; gap: 7px; white-space: n
label.check input { width: auto; }
label.big input { font-family: var(--serif); font-size: 23px; font-weight: 600; padding: 11px 13px; }
.colorpick { display: flex; align-items: center; gap: 8px; }
.colorpick .swatch { width: 22px; height: 22px; border-radius: 6px; border: 1px solid rgba(0,0,0,.15); flex: none; }
.colorpick .swatch { width: 38px; height: 38px; border-radius: 8px; 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: 38px; flex: none; }
.cover-thumb { width: 38px; height: 38px; border-radius: 8px; 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; }