diff --git a/README.md b/README.md index e760028..47a203c 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St | `/feed.xml` | öffentlich | RSS-Feed der Devlogs | | `/profil` | Member | Eigenes Profil: Rang, XP, Rollen (togglebar), Alpha-Key, DSGVO-Löschung | | `/impressum` · `/datenschutz` | öffentlich | Rechtstexte — Betreiber-Angaben aus dem Setup (Owner-only) | +| `/` | öffentlich | **Frei angelegte Seiten** (Regeln, Über uns, FAQ …) — im Seiten-Tab gepflegt | | `/` | ö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 | | `/commits` | nur Admin | Archivierte Commits aller Repos (SHA → Gitea-Link) | diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5fab1ab..c3f8a0f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,6 +15,7 @@ import Home from './pages/Home.jsx'; import DevlogDetail from './pages/DevlogDetail.jsx'; import Impressum from './pages/Impressum.jsx'; import Datenschutz from './pages/Datenschutz.jsx'; +import Seite from './pages/Seite.jsx'; import { IconChevronDown, IconMenu, IconX, IconLock } from './icons.jsx'; 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) const [gated, setGated] = useState(() => new URLSearchParams(window.location.search).has('gate')); const [invite, setInvite] = useState(null); + // Frei angelegte Seiten, die ins Menü sollen + const [pages, setPages] = useState([]); useEffect(() => { apiGet('/api/me') .then((data) => setMe({ scopes: [], ...data, 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(() => { @@ -126,6 +132,9 @@ export default function App() { {COMMUNITY_LINKS.map(([to, label]) => ( {label} ))} + {pages.map((p) => ( + {p.title} + ))} {(me.admin || me.scopes?.length > 0) && Setup} @@ -164,6 +173,9 @@ export default function App() { {COMMUNITY_LINKS.map(([to, label]) => ( {label} ))} + {pages.map((p) => ( + {p.title} + ))} {me.user && Profil} {me.admin && Commits} {(me.admin || me.scopes?.length > 0) && Setup} @@ -204,7 +216,9 @@ export default function App() { } /> } /> } /> - } /> + {/* Frei angelegte Seiten — muss hinter allen festen Routen stehen */} + } /> + } /> diff --git a/frontend/src/icons.jsx b/frontend/src/icons.jsx index 21460d7..51c543e 100644 --- a/frontend/src/icons.jsx +++ b/frontend/src/icons.jsx @@ -46,6 +46,7 @@ export const IconRoles = make(Shield); export const IconSupport = make(Ticket); export const IconApplications = make(FileText); export const IconComposer = make(Pencil); +export const IconPages = make(FileText); export const IconSystem = make(Wrench); export const IconApi = make(Key); export const IconTeam = make(UserCog); diff --git a/frontend/src/pages/Seite.jsx b/frontend/src/pages/Seite.jsx new file mode 100644 index 0000000..7e04c67 --- /dev/null +++ b/frontend/src/pages/Seite.jsx @@ -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 ( +
+
+
// 404
+

Seite nicht gefunden

+

+ Zurück zur Startseite +

+
+
+ ); + } + + return ( + <> +
+
{(page?.title ?? '').slice(0, 12).toUpperCase()}
+
+ +
+ {!page && } + {page?.published === false && ( +

+ Diese Seite ist noch ein Entwurf und für Besucher nicht sichtbar. +

+ )} + {page && ( +
+ +
+ )} +
+ + ); +} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 86ceb8e..441fd27 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -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 = ( +
+

// Eigene Seiten

+

+ 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. +

+ + {pages.length > 0 && ( +
+ {pages.map((p) => ( +
+ {p.title} + /{p.slug} + + {p.published ? (p.in_menu ? 'sichtbar · im Menü' : 'sichtbar · nur per Link') : 'Entwurf'} + + + +
+ ))} +
+ )} + +
+ +
+ setPageDraft({ + ...pageDraft, + title: e.target.value, + // Kürzel nur bei neuen Seiten automatisch mitziehen + slug: pageDraft.isNew ? slugify(e.target.value) : pageDraft.slug, + })} + /> + setPageDraft({ ...pageDraft, slug: slugify(e.target.value) })} + /> + + Adresse: /{pageDraft.slug || '…'} + +
+
+ +
+
+ +