Seiten-Editor: Inhalte pflegen ohne Code-Änderung
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Antwort auf den CMS-Wunsch aus der Community — statt eines Forums, das
neben aktivem Discord erfahrungsgemäß verwaist, ein schlanker Editor für
eigene Seiten: Regeln, Über uns, Mitmachen, FAQ.

- neuer Setup-Tab "Seiten" (Scope content): Titel, Adress-Kürzel wird aus
  dem Titel abgeleitet, Markdown-Editor mit Live-Vorschau daneben
- pro Seite wählbar: veröffentlicht oder Entwurf, im Community-Menü oder
  nur per Direktlink, Reihenfolge
- Anzeige unter /<kürzel>, gerendert mit dem vorhandenen Markdown-Parser
- Entwürfe sind öffentlich nicht abrufbar — sie brauchen den content-Scope
- reservierte Kürzel (devlogs, settings, api, impressum …) werden
  abgelehnt, damit nichts die festen Routen überschreibt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:08:21 +02:00
co-authored by Claude Fable 5
parent 3b80334433
commit e8158ce90b
8 changed files with 337 additions and 2 deletions
+157 -1
View File
@@ -4,8 +4,9 @@ import EmbedComposer from '../components/EmbedComposer.jsx';
import {
IconStatus, IconBrand, IconFeeds, IconCommunity, IconRoles, IconSupport,
IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam,
IconLock, IconBot, IconUpload, IconChevronDown, IconKey,
IconLock, IconBot, IconUpload, IconChevronDown, IconKey, IconPages, IconX,
} from '../icons.jsx';
import { Markdown } from '../markdown.jsx';
const API_SCOPES = [
{ id: 'message', label: 'message', hint: 'Nachrichten/Embeds in Kanäle posten' },
@@ -23,6 +24,7 @@ const TABS = [
{ id: 'support', Icon: IconSupport, label: 'Support' },
{ id: 'bewerbungen', Icon: IconApplications, label: 'Bewerbungen' },
{ id: 'composer', Icon: IconComposer, label: 'Composer' },
{ id: 'seiten', Icon: IconPages, label: 'Seiten' },
{ id: 'server', Icon: IconServer, label: 'Server' },
{ id: 'system', Icon: IconSystem, label: 'System' },
{ id: 'api', Icon: IconApi, label: 'API' },
@@ -39,6 +41,7 @@ const TAB_SCOPES = {
support: 'settings',
bewerbungen: 'bewerbungen',
composer: 'content',
seiten: 'content',
server: 'server',
system: null,
api: null,
@@ -166,6 +169,10 @@ export default function Settings({ me }) {
const [botDesc, setBotDesc] = useState('');
const [team, setTeam] = useState([]);
const [audit, setAudit] = useState([]);
// Eigene Seiten (Regeln, Über uns, …)
const emptyPage = { slug: '', title: '', content: '', published: false, in_menu: true, sort: 0, isNew: true };
const [pages, setPages] = useState([]);
const [pageDraft, setPageDraft] = useState(emptyPage);
// Portal-Dienste (Kacheln auf der Startseite)
const emptyService = { id: null, name: '', url: '', icon: '', description: '', sort: 0 };
const [services, setServices] = useState([]);
@@ -218,6 +225,7 @@ export default function Settings({ me }) {
apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {});
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {});
}, [me.admin, me.scopes?.length]);
useEffect(() => {
@@ -251,6 +259,42 @@ export default function Settings({ me }) {
if (res) setTeam(res.admins);
}
/** Titel → Adress-Kürzel (Umlaute mit auflösen) */
function slugify(value) {
return String(value).toLowerCase()
.replaceAll('ä', 'ae').replaceAll('ö', 'oe').replaceAll('ü', 'ue').replaceAll('ß', 'ss')
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
.slice(0, 40);
}
async function savePageDraft() {
if (!pageDraft.title.trim() || !pageDraft.slug) {
flash('✗ Titel und Kürzel nötig');
return;
}
try {
const res = await apiPut(`/api/pages/${pageDraft.slug}`, pageDraft);
setPages(res.pages.map((p) => ({
...p, published: Boolean(p.published), in_menu: Boolean(p.in_menu),
})));
setPageDraft({ ...pageDraft, isNew: false });
flash('✓ Seite gespeichert');
} catch (e) {
flash(e.status === 400 ? '✗ Kürzel ist reserviert oder ungültig' : '✗ Speichern fehlgeschlagen');
}
}
async function removePage(slug) {
if (!window.confirm(`Seite „/${slug}" wirklich löschen?`)) return;
const res = await apiDelete(`/api/pages/${slug}`).catch(() => null);
if (res) {
setPages(res.pages.map((p) => ({
...p, published: Boolean(p.published), in_menu: Boolean(p.in_menu),
})));
if (pageDraft.slug === slug) setPageDraft(emptyPage);
}
}
async function saveService() {
if (!serviceDraft.name.trim() || !serviceDraft.url.trim()) {
flash('✗ Name und URL nötig');
@@ -1614,6 +1658,117 @@ export default function Settings({ me }) {
</>
);
const tabSeiten = (
<div className="settings-section">
<h2 className="settings-title">// Eigene Seiten</h2>
<p className="section-intro">
Inhalte pflegen, ohne dass jemand Code anfassen muss Regeln, Über uns,
Mitmachen, FAQ. Geschrieben wird in Markdown (wie im Devlog), rechts siehst
du sofort, wie es aussieht.
</p>
{pages.length > 0 && (
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
{pages.map((p) => (
<div className="key-row" key={p.slug}>
<span className="key-name">{p.title}</span>
<span className="key-scopes">/{p.slug}</span>
<span className="key-used">
{p.published ? (p.in_menu ? 'sichtbar · im Menü' : 'sichtbar · nur per Link') : 'Entwurf'}
</span>
<button className="btn btn-mini" onClick={() => setPageDraft({
slug: p.slug, title: p.title, content: p.content,
published: p.published, in_menu: p.in_menu, sort: p.sort ?? 0,
isNew: false,
})}>Bearbeiten</button>
<button className="card-delete" title="Seite löschen" onClick={() => removePage(p.slug)}>
<IconX size={15} />
</button>
</div>
))}
</div>
)}
<div className="field">
<label>{pageDraft.isNew ? 'Neue Seite' : `Bearbeiten: /${pageDraft.slug}`}</label>
<div className="field-row">
<input
type="text" style={{ flex: 'none', width: '14rem' }} placeholder="Titel, z. B. Regeln"
value={pageDraft.title}
onChange={(e) => setPageDraft({
...pageDraft,
title: e.target.value,
// Kürzel nur bei neuen Seiten automatisch mitziehen
slug: pageDraft.isNew ? slugify(e.target.value) : pageDraft.slug,
})}
/>
<input
type="text" style={{ flex: 'none', width: '11rem' }} placeholder="kuerzel"
value={pageDraft.slug}
onChange={(e) => setPageDraft({ ...pageDraft, slug: slugify(e.target.value) })}
/>
<span className="field-hint" style={{ alignSelf: 'center' }}>
Adresse: /{pageDraft.slug || ''}
</span>
</div>
</div>
<div className="composer-grid">
<div className="field">
<label>Inhalt <span className="field-hint">Markdown: **fett**, *kursiv*, ## Überschrift, - Liste, [Link](url)</span></label>
<textarea
rows={18} placeholder={'## Willkommen\n\nHier stehen unsere Regeln.\n\n- Sei nett\n- Kein Spam'}
value={pageDraft.content}
onChange={(e) => setPageDraft({ ...pageDraft, content: e.target.value })}
/>
</div>
<div className="field">
<label>Vorschau</label>
<div className="card page-preview">
<div className="card-body">
{pageDraft.content.trim()
? <Markdown text={pageDraft.content} />
: <p className="field-hint">Noch kein Inhalt.</p>}
</div>
</div>
</div>
</div>
<div className="field">
<label className="toggle">
<input
type="checkbox" checked={pageDraft.published}
onChange={(e) => setPageDraft({ ...pageDraft, published: e.target.checked })}
/>
<span>Veröffentlicht</span>
<span className="field-hint">aus = Entwurf, nur für dich sichtbar</span>
</label>
<label className="toggle" style={{ marginTop: '.5rem' }}>
<input
type="checkbox" checked={pageDraft.in_menu}
onChange={(e) => setPageDraft({ ...pageDraft, in_menu: e.target.checked })}
/>
<span>Im Community-Menü zeigen</span>
<span className="field-hint">aus = nur über den Direktlink erreichbar</span>
</label>
</div>
<div className="field-row">
<input
type="number" style={{ flex: 'none', width: '7rem' }} placeholder="Position"
value={pageDraft.sort}
onChange={(e) => setPageDraft({ ...pageDraft, sort: e.target.value })}
/>
<button className="btn btn-save" onClick={savePageDraft}>
{pageDraft.isNew ? 'Anlegen' : 'Speichern'}
</button>
{!pageDraft.isNew && (
<button className="btn btn-ghost" onClick={() => setPageDraft(emptyPage)}>Neue Seite</button>
)}
</div>
</div>
);
const tabTeam = (
<div className="settings-section">
<h2 className="settings-title">// Team</h2>
@@ -1696,6 +1851,7 @@ export default function Settings({ me }) {
support: tabSupport,
bewerbungen: tabBewerbungen,
composer: tabComposer,
seiten: tabSeiten,
server: tabServer,
system: tabSystem,
api: tabApi,