Seiten-Editor: Inhalte pflegen ohne Code-Änderung
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:
@@ -73,6 +73,7 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
|
|||||||
| `/feed.xml` | öffentlich | RSS-Feed der Devlogs |
|
| `/feed.xml` | öffentlich | RSS-Feed der Devlogs |
|
||||||
| `/profil` | Member | Eigenes Profil: Rang, XP, Rollen (togglebar), Alpha-Key, DSGVO-Löschung |
|
| `/profil` | Member | Eigenes Profil: Rang, XP, Rollen (togglebar), Alpha-Key, DSGVO-Löschung |
|
||||||
| `/impressum` · `/datenschutz` | öffentlich | Rechtstexte — Betreiber-Angaben aus dem Setup (Owner-only) |
|
| `/impressum` · `/datenschutz` | öffentlich | Rechtstexte — Betreiber-Angaben aus dem Setup (Owner-only) |
|
||||||
|
| `/<kürzel>` | öffentlich | **Frei angelegte Seiten** (Regeln, Über uns, FAQ …) — im Seiten-Tab gepflegt |
|
||||||
| `/` | öffentlich | Startseite: Hero, Live-Zahlen (Member/Server/Spieler), Bereichs-Kacheln, neuestes Devlog |
|
| `/` | öffentlich | Startseite: Hero, Live-Zahlen (Member/Server/Spieler), Bereichs-Kacheln, neuestes Devlog |
|
||||||
| `/server` | öffentlich | Live-Status aller Game-Server: Logo, Spieler-Balken, 24h-Verlauf, Uptime & Peak, Copy-Adresse |
|
| `/server` | öffentlich | Live-Status aller Game-Server: Logo, Spieler-Balken, 24h-Verlauf, Uptime & Peak, Copy-Adresse |
|
||||||
| `/commits` | nur Admin | Archivierte Commits aller Repos (SHA → Gitea-Link) |
|
| `/commits` | nur Admin | Archivierte Commits aller Repos (SHA → Gitea-Link) |
|
||||||
|
|||||||
+15
-1
@@ -15,6 +15,7 @@ import Home from './pages/Home.jsx';
|
|||||||
import DevlogDetail from './pages/DevlogDetail.jsx';
|
import DevlogDetail from './pages/DevlogDetail.jsx';
|
||||||
import Impressum from './pages/Impressum.jsx';
|
import Impressum from './pages/Impressum.jsx';
|
||||||
import Datenschutz from './pages/Datenschutz.jsx';
|
import Datenschutz from './pages/Datenschutz.jsx';
|
||||||
|
import Seite from './pages/Seite.jsx';
|
||||||
import { IconChevronDown, IconMenu, IconX, IconLock } from './icons.jsx';
|
import { IconChevronDown, IconMenu, IconX, IconLock } from './icons.jsx';
|
||||||
|
|
||||||
const COMMUNITY_LINKS = [
|
const COMMUNITY_LINKS = [
|
||||||
@@ -92,11 +93,16 @@ export default function App() {
|
|||||||
// Member-Gate: OAuth war okay, aber User ist nicht auf dem Discord (?gate=1)
|
// Member-Gate: OAuth war okay, aber User ist nicht auf dem Discord (?gate=1)
|
||||||
const [gated, setGated] = useState(() => new URLSearchParams(window.location.search).has('gate'));
|
const [gated, setGated] = useState(() => new URLSearchParams(window.location.search).has('gate'));
|
||||||
const [invite, setInvite] = useState(null);
|
const [invite, setInvite] = useState(null);
|
||||||
|
// Frei angelegte Seiten, die ins Menü sollen
|
||||||
|
const [pages, setPages] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiGet('/api/me')
|
apiGet('/api/me')
|
||||||
.then((data) => setMe({ scopes: [], ...data, loading: false }))
|
.then((data) => setMe({ scopes: [], ...data, loading: false }))
|
||||||
.catch(() => setMe({ user: null, admin: false, scopes: [], loading: false }));
|
.catch(() => setMe({ user: null, admin: false, scopes: [], loading: false }));
|
||||||
|
apiGet('/api/pages')
|
||||||
|
.then((d) => setPages(d.pages.filter((p) => p.in_menu)))
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -126,6 +132,9 @@ export default function App() {
|
|||||||
{COMMUNITY_LINKS.map(([to, label]) => (
|
{COMMUNITY_LINKS.map(([to, label]) => (
|
||||||
<NavLink to={to} key={to}>{label}</NavLink>
|
<NavLink to={to} key={to}>{label}</NavLink>
|
||||||
))}
|
))}
|
||||||
|
{pages.map((p) => (
|
||||||
|
<NavLink to={`/${p.slug}`} key={p.slug}>{p.title}</NavLink>
|
||||||
|
))}
|
||||||
</NavDropdown>
|
</NavDropdown>
|
||||||
{(me.admin || me.scopes?.length > 0) && <NavLink to="/settings">Setup</NavLink>}
|
{(me.admin || me.scopes?.length > 0) && <NavLink to="/settings">Setup</NavLink>}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -164,6 +173,9 @@ export default function App() {
|
|||||||
{COMMUNITY_LINKS.map(([to, label]) => (
|
{COMMUNITY_LINKS.map(([to, label]) => (
|
||||||
<NavLink to={to} key={to}>{label}</NavLink>
|
<NavLink to={to} key={to}>{label}</NavLink>
|
||||||
))}
|
))}
|
||||||
|
{pages.map((p) => (
|
||||||
|
<NavLink to={`/${p.slug}`} key={p.slug}>{p.title}</NavLink>
|
||||||
|
))}
|
||||||
{me.user && <NavLink to="/profil">Profil</NavLink>}
|
{me.user && <NavLink to="/profil">Profil</NavLink>}
|
||||||
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
||||||
{(me.admin || me.scopes?.length > 0) && <NavLink to="/settings">Setup</NavLink>}
|
{(me.admin || me.scopes?.length > 0) && <NavLink to="/settings">Setup</NavLink>}
|
||||||
@@ -204,7 +216,9 @@ export default function App() {
|
|||||||
<Route path="/datenschutz" element={<Datenschutz />} />
|
<Route path="/datenschutz" element={<Datenschutz />} />
|
||||||
<Route path="/commits" element={<Commits me={me} />} />
|
<Route path="/commits" element={<Commits me={me} />} />
|
||||||
<Route path="/settings" element={<Settings me={me} />} />
|
<Route path="/settings" element={<Settings me={me} />} />
|
||||||
<Route path="*" element={<Navigate to="/devlogs" replace />} />
|
{/* Frei angelegte Seiten — muss hinter allen festen Routen stehen */}
|
||||||
|
<Route path="/:slug" element={<Seite />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const IconRoles = make(Shield);
|
|||||||
export const IconSupport = make(Ticket);
|
export const IconSupport = make(Ticket);
|
||||||
export const IconApplications = make(FileText);
|
export const IconApplications = make(FileText);
|
||||||
export const IconComposer = make(Pencil);
|
export const IconComposer = make(Pencil);
|
||||||
|
export const IconPages = make(FileText);
|
||||||
export const IconSystem = make(Wrench);
|
export const IconSystem = make(Wrench);
|
||||||
export const IconApi = make(Key);
|
export const IconApi = make(Key);
|
||||||
export const IconTeam = make(UserCog);
|
export const IconTeam = make(UserCog);
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Anzeige einer frei angelegten Seite (/:slug) — Inhalt kommt aus dem Setup.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { apiGet } from '../api.js';
|
||||||
|
import { Markdown } from '../markdown.jsx';
|
||||||
|
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||||
|
|
||||||
|
export default function Seite() {
|
||||||
|
const { slug } = useParams();
|
||||||
|
const [page, setPage] = useState(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(null);
|
||||||
|
setError(false);
|
||||||
|
apiGet(`/api/pages/${slug}`).then((d) => setPage(d.page)).catch(() => setError(true));
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<section className="page-header">
|
||||||
|
<div className="page-header-content">
|
||||||
|
<div className="page-tag">// 404</div>
|
||||||
|
<h1 className="page-title">Seite nicht gefunden</h1>
|
||||||
|
<p className="page-subtitle">
|
||||||
|
<Link to="/" style={{ textDecoration: 'underline' }}>Zurück zur Startseite</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-header">
|
||||||
|
<div className="page-bg-text">{(page?.title ?? '').slice(0, 12).toUpperCase()}</div>
|
||||||
|
<div className="page-header-grid" aria-hidden="true" />
|
||||||
|
<div className="page-header-content">
|
||||||
|
<div className="page-tag">// {page?.published === false ? 'Entwurf' : 'Info'}</div>
|
||||||
|
<h1 className="page-title">{page?.title ?? ' '}</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="content legal-text">
|
||||||
|
{!page && <SkeletonCards n={1} />}
|
||||||
|
{page?.published === false && (
|
||||||
|
<p className="notice">
|
||||||
|
Diese Seite ist noch ein Entwurf und für Besucher nicht sichtbar.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{page && (
|
||||||
|
<div className="card-body">
|
||||||
|
<Markdown text={page.content} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,9 @@ import EmbedComposer from '../components/EmbedComposer.jsx';
|
|||||||
import {
|
import {
|
||||||
IconStatus, IconBrand, IconFeeds, IconCommunity, IconRoles, IconSupport,
|
IconStatus, IconBrand, IconFeeds, IconCommunity, IconRoles, IconSupport,
|
||||||
IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam,
|
IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam,
|
||||||
IconLock, IconBot, IconUpload, IconChevronDown, IconKey,
|
IconLock, IconBot, IconUpload, IconChevronDown, IconKey, IconPages, IconX,
|
||||||
} from '../icons.jsx';
|
} from '../icons.jsx';
|
||||||
|
import { Markdown } from '../markdown.jsx';
|
||||||
|
|
||||||
const API_SCOPES = [
|
const API_SCOPES = [
|
||||||
{ id: 'message', label: 'message', hint: 'Nachrichten/Embeds in Kanäle posten' },
|
{ id: 'message', label: 'message', hint: 'Nachrichten/Embeds in Kanäle posten' },
|
||||||
@@ -23,6 +24,7 @@ const TABS = [
|
|||||||
{ id: 'support', Icon: IconSupport, label: 'Support' },
|
{ id: 'support', Icon: IconSupport, label: 'Support' },
|
||||||
{ id: 'bewerbungen', Icon: IconApplications, label: 'Bewerbungen' },
|
{ id: 'bewerbungen', Icon: IconApplications, label: 'Bewerbungen' },
|
||||||
{ id: 'composer', Icon: IconComposer, label: 'Composer' },
|
{ id: 'composer', Icon: IconComposer, label: 'Composer' },
|
||||||
|
{ id: 'seiten', Icon: IconPages, label: 'Seiten' },
|
||||||
{ id: 'server', Icon: IconServer, label: 'Server' },
|
{ id: 'server', Icon: IconServer, label: 'Server' },
|
||||||
{ id: 'system', Icon: IconSystem, label: 'System' },
|
{ id: 'system', Icon: IconSystem, label: 'System' },
|
||||||
{ id: 'api', Icon: IconApi, label: 'API' },
|
{ id: 'api', Icon: IconApi, label: 'API' },
|
||||||
@@ -39,6 +41,7 @@ const TAB_SCOPES = {
|
|||||||
support: 'settings',
|
support: 'settings',
|
||||||
bewerbungen: 'bewerbungen',
|
bewerbungen: 'bewerbungen',
|
||||||
composer: 'content',
|
composer: 'content',
|
||||||
|
seiten: 'content',
|
||||||
server: 'server',
|
server: 'server',
|
||||||
system: null,
|
system: null,
|
||||||
api: null,
|
api: null,
|
||||||
@@ -166,6 +169,10 @@ export default function Settings({ me }) {
|
|||||||
const [botDesc, setBotDesc] = useState('');
|
const [botDesc, setBotDesc] = useState('');
|
||||||
const [team, setTeam] = useState([]);
|
const [team, setTeam] = useState([]);
|
||||||
const [audit, setAudit] = 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)
|
// Portal-Dienste (Kacheln auf der Startseite)
|
||||||
const emptyService = { id: null, name: '', url: '', icon: '', description: '', sort: 0 };
|
const emptyService = { id: null, name: '', url: '', icon: '', description: '', sort: 0 };
|
||||||
const [services, setServices] = useState([]);
|
const [services, setServices] = useState([]);
|
||||||
@@ -218,6 +225,7 @@ export default function Settings({ me }) {
|
|||||||
apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {});
|
apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {});
|
||||||
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
|
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
|
||||||
apiGet('/api/services').then((d) => setServices(d.services)).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]);
|
}, [me.admin, me.scopes?.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -251,6 +259,42 @@ export default function Settings({ me }) {
|
|||||||
if (res) setTeam(res.admins);
|
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() {
|
async function saveService() {
|
||||||
if (!serviceDraft.name.trim() || !serviceDraft.url.trim()) {
|
if (!serviceDraft.name.trim() || !serviceDraft.url.trim()) {
|
||||||
flash('✗ Name und URL nötig');
|
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 = (
|
const tabTeam = (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Team</h2>
|
<h2 className="settings-title">// Team</h2>
|
||||||
@@ -1696,6 +1851,7 @@ export default function Settings({ me }) {
|
|||||||
support: tabSupport,
|
support: tabSupport,
|
||||||
bewerbungen: tabBewerbungen,
|
bewerbungen: tabBewerbungen,
|
||||||
composer: tabComposer,
|
composer: tabComposer,
|
||||||
|
seiten: tabSeiten,
|
||||||
server: tabServer,
|
server: tabServer,
|
||||||
system: tabSystem,
|
system: tabSystem,
|
||||||
api: tabApi,
|
api: tabApi,
|
||||||
|
|||||||
@@ -1563,3 +1563,9 @@ button.with-icon, a.with-icon { justify-content: center; }
|
|||||||
|
|
||||||
/* Discord-Vorschau im Composer */
|
/* Discord-Vorschau im Composer */
|
||||||
.dpv-avatar { display: flex; align-items: center; justify-content: center; color: var(--muted); }
|
.dpv-avatar { display: flex; align-items: center; justify-content: center; color: var(--muted); }
|
||||||
|
|
||||||
|
/* Vorschau im Seiten-Editor */
|
||||||
|
.page-preview {
|
||||||
|
max-height: 26rem; overflow-y: auto;
|
||||||
|
background: var(--bg2);
|
||||||
|
}
|
||||||
|
|||||||
@@ -516,6 +516,39 @@ export function deleteUserData(userId) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Frei anlegbare Seiten (Regeln, Über uns, Mitmachen …) — Inhalte pflegen
|
||||||
|
// ohne Code-Änderung. Rechtstexte laufen bewusst separat.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS pages (
|
||||||
|
slug TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
published INTEGER NOT NULL DEFAULT 0,
|
||||||
|
in_menu INTEGER NOT NULL DEFAULT 1,
|
||||||
|
sort INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const upsertPage = db.prepare(`
|
||||||
|
INSERT INTO pages (slug, title, content, published, in_menu, sort, updated_at)
|
||||||
|
VALUES (@slug, @title, @content, @published, @in_menu, @sort, datetime('now'))
|
||||||
|
ON CONFLICT(slug) DO UPDATE SET
|
||||||
|
title = excluded.title, content = excluded.content,
|
||||||
|
published = excluded.published, in_menu = excluded.in_menu,
|
||||||
|
sort = excluded.sort, updated_at = datetime('now')
|
||||||
|
`);
|
||||||
|
const getPageStmt = db.prepare('SELECT * FROM pages WHERE slug = ?');
|
||||||
|
const listPagesStmt = db.prepare('SELECT * FROM pages ORDER BY sort, title');
|
||||||
|
const listPublishedPagesStmt = db.prepare(
|
||||||
|
'SELECT slug, title, in_menu, sort FROM pages WHERE published = 1 ORDER BY sort, title'
|
||||||
|
);
|
||||||
|
const deletePageStmt = db.prepare('DELETE FROM pages WHERE slug = ?');
|
||||||
|
export const savePage = (p) => upsertPage.run(p);
|
||||||
|
export const getPage = (slug) => getPageStmt.get(slug) ?? null;
|
||||||
|
export const listPages = () => listPagesStmt.all();
|
||||||
|
export const listPublishedPages = () => listPublishedPagesStmt.all();
|
||||||
|
export const deletePage = (slug) => deletePageStmt.run(slug).changes > 0;
|
||||||
|
|
||||||
// Portal: eigene Dienste (Gitea, Kanban, Cloud …) für die Kachel-Übersicht
|
// Portal: eigene Dienste (Gitea, Kanban, Cloud …) für die Kachel-Übersicht
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS services (
|
CREATE TABLE IF NOT EXISTS services (
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
logAudit, listAudit, playerHistory, deleteUserData,
|
logAudit, listAudit, playerHistory, deleteUserData,
|
||||||
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
|
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
|
||||||
createService, updateService, listServices, deleteService,
|
createService, updateService, listServices, deleteService,
|
||||||
|
savePage, getPage, listPages, listPublishedPages, deletePage,
|
||||||
} from '../db.js';
|
} from '../db.js';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
|
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
|
||||||
@@ -163,6 +164,69 @@ export function registerApiRoutes(app, client) {
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// --- Frei angelegte Seiten (Regeln, Über uns, …) ---
|
||||||
|
|
||||||
|
// Adressen, die schon vom Frontend oder Server belegt sind
|
||||||
|
const RESERVED_SLUGS = new Set([
|
||||||
|
'devlogs', 'roadmap', 'server', 'galerie', 'level', 'events', 'changelog',
|
||||||
|
'profil', 'commits', 'settings', 'dashboard', 'impressum', 'datenschutz',
|
||||||
|
'api', 'auth', 'webhooks', 'sso', 'health', 'assets', 'features', 'commands', 'docs',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Menü-Einträge + veröffentlichte Seiten — öffentlich
|
||||||
|
app.get('/api/pages', async () => ({ pages: listPublishedPages() }));
|
||||||
|
|
||||||
|
app.get('/api/pages/:slug', async (request, reply) => {
|
||||||
|
const page = getPage(String(request.params.slug));
|
||||||
|
if (!page || !page.published) {
|
||||||
|
// Entwürfe darf nur sehen, wer sie auch bearbeiten dürfte
|
||||||
|
const scopes = scopesOf(getSessionUser(request));
|
||||||
|
if (!page || !(scopes.includes('*') || scopes.includes('content'))) {
|
||||||
|
return reply.code(404).send({ error: 'Seite nicht gefunden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { page: { ...page, published: Boolean(page.published), in_menu: Boolean(page.in_menu) } };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verwaltung (Scope content) — inklusive Entwürfen
|
||||||
|
app.get('/api/pages/all/list', async (request, reply) => {
|
||||||
|
if (requireScope(request, reply, 'content')) return;
|
||||||
|
return {
|
||||||
|
pages: listPages().map((p) => ({
|
||||||
|
...p, published: Boolean(p.published), in_menu: Boolean(p.in_menu),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/pages/:slug', async (request, reply) => {
|
||||||
|
if (requireScope(request, reply, 'content')) return;
|
||||||
|
const slug = String(request.params.slug).trim().toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
const title = String(request.body?.title ?? '').trim();
|
||||||
|
|
||||||
|
if (!slug || !title) return reply.code(400).send({ error: 'Kürzel und Titel nötig' });
|
||||||
|
if (RESERVED_SLUGS.has(slug)) {
|
||||||
|
return reply.code(400).send({ error: `„${slug}" ist bereits vergeben — bitte anderes Kürzel` });
|
||||||
|
}
|
||||||
|
|
||||||
|
savePage({
|
||||||
|
slug,
|
||||||
|
title: title.slice(0, 80),
|
||||||
|
content: String(request.body?.content ?? '').slice(0, 50_000),
|
||||||
|
published: request.body?.published ? 1 : 0,
|
||||||
|
in_menu: request.body?.in_menu === false ? 0 : 1,
|
||||||
|
sort: Number(request.body?.sort) || 0,
|
||||||
|
});
|
||||||
|
logAudit(getSessionUser(request), 'seite gespeichert', `${title} (/${slug})`);
|
||||||
|
return { ok: true, slug, pages: listPages() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/pages/:slug', async (request, reply) => {
|
||||||
|
if (requireScope(request, reply, 'content')) return;
|
||||||
|
logAudit(getSessionUser(request), 'seite gelöscht', String(request.params.slug));
|
||||||
|
return { deleted: deletePage(String(request.params.slug)), pages: listPages() };
|
||||||
|
});
|
||||||
|
|
||||||
// Dienste-Kacheln fürs Portal — öffentlich
|
// Dienste-Kacheln fürs Portal — öffentlich
|
||||||
app.get('/api/services', async () => ({ services: listServices() }));
|
app.get('/api/services', async () => ({ services: listServices() }));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user