diff --git a/README.md b/README.md index b2eef48..df96640 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St | 🎮 **Server-Monitor** | DiscordGSM-Stil: eigener Setup-Tab, pro Server ein Live-Embed (🟢/🔴, Spieler-Balken mit %, Map, Spieler-Liste, klickbarer Connect-Link, Ping); **300+ Spiele via gamedig** (Minecraft, Rust, CS2, Valheim, ARK …) + FiveM + HTTP-Check; Down/Up-Alerts (🚨 nach 2 Fehlversuchen, ✅ mit Downtime bei Recovery) in eigenen Alert-Kanal; Spielerzahl in der Presence | | 👥 **Team-Rechte** | Team-Mitglieder bekommen gezielten Web-Zugriff (Composer, Bewerbungen, Rollen, Server, …) — Brand, System, API-Keys bleiben Owner-only; **Audit-Log** im Team-Tab zeigt dem Owner die letzten Aktionen | | 🔑 **Single Sign-On** | Andere Dienste (Kanban, Platform …) nutzen den Discord-Login des Bots mit und bekommen die Discord-Rollen gleich mitgeliefert — [Anleitung](docs/sso.md) | +| 🎨 **Geteiltes Design** | `/brand.css` + `/brand-nav.js` geben jeder anderen App den D4RKST3R-Look samt Navigation — Farben kommen live aus dem Brand-Tab | +| 🏠 **Portal** | Dienste (Gitea, Kanban, Cloud …) im Setup pflegen → Kacheln auf der Startseite und Links in der geteilten Navigation | | ⚖️ **Rechtstexte** | `/impressum` + `/datenschutz` aus dem Setup gepflegt; Member können ihre Daten selbst löschen (DSGVO Art. 17) | | 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite | | 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung nach Ablauf; am Gewinner-Post: 🔁 Neu auslosen (Admin) + 👥 Teilnehmerliste | @@ -274,6 +276,8 @@ urllib.request.urlopen(req) | `POST /api/compose` · `GET/PUT/DELETE /api/templates` | Team (content) | Embed-Composer senden/bearbeiten · Vorlagen | | `GET /api/profile` · `GET/POST /api/myroles*` · `POST /api/wishes*` | Member | Profil · Rollen-Selfservice · Wunsch einreichen/voten | | `GET /sso/authorize` · `POST /sso/verify` | App-Secret | **Single Sign-On** für andere Dienste — siehe [docs/sso.md](docs/sso.md) | +| `GET /brand.css` · `/brand-nav.js` | — | Design-Tokens + Navigation für andere Apps (CORS offen) | +| `GET /api/services` | — | Dienste-Kacheln fürs Portal (Pflege: Scope `settings`) | | `GET /api/legal` · `DELETE /api/profile` | — / Member | Impressums-Angaben · DSGVO-Löschung | | `GET/PUT /api/settings`, `POST /api/settings/test/:target` | Admin | Setup-Seite | | `GET /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen | diff --git a/docs/sso.md b/docs/sso.md index 9a6b72f..4091172 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -101,7 +101,32 @@ app.get('/auth/callback', async (req, res) => { }); ``` -## 5. Hinweise +## 5. Aussehen mitnehmen (Design-System) + +Damit die angebundene App nicht wie ein Fremdkörper wirkt, liefert der Bot die +Brand-Tokens und eine fertige Navigation aus: + +```html + + +``` + +* **`/brand.css`** — CSS-Variablen (`--neon`, `--bg`, `--display` …) plus ein paar + Basis-Klassen: `.d4rk-card`, `.d4rk-btn`, `.d4rk-title`, `.d4rk-tag`. + Die Farben kommen live aus dem Brand-Tab — änderst du sie dort, ändern sie sich + überall mit (Cache: 5 Minuten). +* **`/brand-nav.js`** — fügt oben die D4RKST3R-Leiste ein, mit Links zurück zum Hub + und zu allen im Setup gepflegten Diensten. Die Links stecken fertig im Skript, + es sind also keine weiteren Anfragen und kein CORS nötig. + +Die Schriften installierst du in der App selbst, damit sie lokal ausgeliefert werden +(kein Google-Fonts-Aufruf): + +```bash +npm i @fontsource/bebas-neue @fontsource/barlow-condensed @fontsource/share-tech-mono +``` + +## 6. Hinweise * Der Verify-Aufruf muss **serverseitig** erfolgen — das Secret darf nie im Browser landen. * Nur Mitglieder des konfigurierten Discord-Servers können sich anmelden; alle anderen diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 3d584af..b1e842a 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -28,12 +28,14 @@ export default function Home() { const [servers, setServers] = useState(null); const [latest, setLatest] = useState(null); const [invite, setInvite] = useState(null); + const [services, setServices] = useState([]); useEffect(() => { apiGet('/api/serverstats').then(setStats).catch(() => {}); apiGet('/api/servers').then((d) => setServers(d.servers)).catch(() => {}); apiGet('/api/devlogs?page=1').then((d) => setLatest(d.items?.[0] ?? null)).catch(() => {}); apiGet('/api/invite').then((d) => setInvite(d.invite)).catch(() => {}); + apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {}); window.scrollTo(0, 0); }, []); @@ -95,6 +97,21 @@ export default function Home() { ))} + {services.length > 0 && ( +
+ Erscheinen als Kacheln auf der Startseite — z. B. Gitea, Kanban oder die Cloud. +
+ {services.length > 0 && ( +diff --git a/frontend/src/style.css b/frontend/src/style.css index 93c401b..2640251 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1511,3 +1511,4 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); } } .btn-danger { border-color: rgba(242, 63, 67, .5); color: #f23f43; } .btn-danger:hover { border-color: #f23f43; background: rgba(242, 63, 67, .1); } +.home-tile-ext { font-size: .8rem; color: var(--muted2); } diff --git a/src/db.js b/src/db.js index c8f80fa..8f21e76 100644 --- a/src/db.js +++ b/src/db.js @@ -516,6 +516,30 @@ export function deleteUserData(userId) { return result; } +// Portal: eigene Dienste (Gitea, Kanban, Cloud …) für die Kachel-Übersicht +db.exec(` + CREATE TABLE IF NOT EXISTS services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '🔗', + description TEXT NOT NULL DEFAULT '', + sort INTEGER NOT NULL DEFAULT 0 + ); +`); +const insertService = db.prepare( + 'INSERT INTO services (name, url, icon, description, sort) VALUES (@name, @url, @icon, @description, @sort)' +); +const updateServiceStmt = db.prepare( + 'UPDATE services SET name = @name, url = @url, icon = @icon, description = @description, sort = @sort WHERE id = @id' +); +const listServicesStmt = db.prepare('SELECT * FROM services ORDER BY sort, name'); +const deleteServiceStmt = db.prepare('DELETE FROM services WHERE id = ?'); +export const createService = (s) => insertService.run(s).lastInsertRowid; +export const updateService = (s) => updateServiceStmt.run(s).changes > 0; +export const listServices = () => listServicesStmt.all(); +export const deleteService = (id) => deleteServiceStmt.run(id).changes > 0; + // SSO: Fremd-Apps (Kanban, Platform …), die den Discord-Login des Bots mitnutzen db.exec(` CREATE TABLE IF NOT EXISTS sso_apps ( diff --git a/src/web/api.js b/src/web/api.js index 154c538..78dead9 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -16,6 +16,7 @@ import { getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes, logAudit, listAudit, playerHistory, deleteUserData, saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp, + createService, updateService, listServices, deleteService, } from '../db.js'; import crypto from 'node:crypto'; import { lastResults, GAME_ICONS } from '../bot/server-monitor.js'; @@ -162,6 +163,51 @@ export function registerApiRoutes(app, client) { }), })); + // Dienste-Kacheln fürs Portal — öffentlich + app.get('/api/services', async () => ({ services: listServices() })); + + /** Dienst-Body prüfen; antwortet bei Fehlern selbst */ + function parseServiceBody(request, reply) { + const body = request.body ?? {}; + const name = String(body.name ?? '').trim(); + const url = String(body.url ?? '').trim(); + if (!name || !/^https?:\/\/.+/.test(url)) { + reply.code(400).send({ error: 'name und url (http/https) nötig' }); + return null; + } + return { + name: name.slice(0, 60), + url: url.slice(0, 300), + icon: (String(body.icon ?? '').trim() || '🔗').slice(0, 8), + description: String(body.description ?? '').trim().slice(0, 160), + sort: Number(body.sort) || 0, + }; + } + + app.post('/api/services', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const service = parseServiceBody(request, reply); + if (!service) return; + const id = createService(service); + logAudit(getSessionUser(request), 'dienst hinzugefügt', service.name); + return { ok: true, services: listServices(), id }; + }); + + app.put('/api/services/:id', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const service = parseServiceBody(request, reply); + if (!service) return; + updateService({ ...service, id: Number(request.params.id) }); + logAudit(getSessionUser(request), 'dienst geändert', service.name); + return { ok: true, services: listServices() }; + }); + + app.delete('/api/services/:id', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + logAudit(getSessionUser(request), 'dienst gelöscht', String(request.params.id)); + return { deleted: deleteService(Number(request.params.id)), services: listServices() }; + }); + // Level-Bestenliste + Server-Aktivität — öffentlich app.get('/api/levels', async () => ({ levels: topLevels(50) })); app.get('/api/serverstats', async () => ({ diff --git a/src/web/brand.js b/src/web/brand.js new file mode 100644 index 0000000..3e4b35a --- /dev/null +++ b/src/web/brand.js @@ -0,0 +1,167 @@ +// Geteiltes Design-System: andere Dienste (Kanban, Platform …) binden diese +// beiden Dateien ein und sehen sofort aus wie der Rest — Farben kommen live aus +// dem Brand-Tab, eine Änderung dort wirkt überall. +// +// +// +// +// Die Dienste-Links werden serverseitig eingebettet — die Fremd-App braucht +// dadurch keinen CORS-Zugriff auf die API. +import { listServices } from '../db.js'; +import { brandName, publicUrl } from '../runtime-settings.js'; +import { getSetting } from '../db.js'; + +const CACHE_SECONDS = 300; + +/** In JS-String einbetten (Anführungszeichen/Zeilenumbrüche entschärfen) */ +const js = (value) => JSON.stringify(String(value ?? '')); + +export function registerBrandRoutes(app) { + app.get('/brand.css', async (request, reply) => { + const neon = getSetting('brand_color') || '#f5c518'; + const neon2 = getSetting('brand_color2') || '#ff4d00'; + + return reply + .type('text/css; charset=utf-8') + .header('cache-control', `public, max-age=${CACHE_SECONDS}`) + .header('access-control-allow-origin', '*') + .send(`/* D4RKST3R Design-Tokens — erzeugt von ${publicUrl()} */ +:root { + --neon: ${neon}; + --neon2: ${neon2}; + --bg: #0a0a0a; + --bg2: #111; + --bg3: #161616; + --text: #e8e0d0; + --muted: #8a8378; + --muted2: #666; + --border: color-mix(in srgb, ${neon} 15%, transparent); + --success: #00ff88; + --danger: #f23f43; + /* Schriften per @fontsource in der App installieren: + npm i @fontsource/bebas-neue @fontsource/barlow-condensed @fontsource/share-tech-mono */ + --display: 'Bebas Neue', Impact, sans-serif; + --body: 'Barlow Condensed', system-ui, sans-serif; + --mono: 'Share Tech Mono', ui-monospace, monospace; +} + +.d4rk-body { + background: var(--bg); color: var(--text); + font-family: var(--body); font-size: 17px; line-height: 1.6; +} +.d4rk-title { + font-family: var(--display); letter-spacing: .03em; + color: var(--text); line-height: .95; +} +.d4rk-tag { + font-family: var(--mono); font-size: .7rem; letter-spacing: .3em; + text-transform: uppercase; color: var(--neon2); +} +.d4rk-card { + background: var(--bg2); border: 1px solid rgba(255,255,255,.06); + border-radius: 12px; padding: 1.3rem 1.6rem; + transition: border-color .25s, transform .25s, box-shadow .25s; +} +.d4rk-card:hover { + border-color: var(--border); transform: translateY(-2px); + box-shadow: 0 10px 30px rgba(0,0,0,.45); +} +.d4rk-btn { + display: inline-flex; align-items: center; gap: .5rem; + font-family: var(--mono); font-size: .7rem; + letter-spacing: .2em; text-transform: uppercase; + color: var(--neon); background: none; + border: 1px solid var(--border); border-radius: 999px; + padding: .5rem 1.2rem; cursor: pointer; + transition: background .2s, border-color .2s, box-shadow .2s; +} +.d4rk-btn:hover { + border-color: var(--neon); background: color-mix(in srgb, var(--neon) 8%, transparent); + box-shadow: 0 0 16px color-mix(in srgb, var(--neon) 15%, transparent); +} + +/* Navbar, die brand-nav.js einfügt */ +.d4rk-nav { + position: sticky; top: 0; z-index: 200; + display: flex; align-items: center; gap: 1.6rem; flex-wrap: wrap; + padding: 1rem 2rem; + background: rgba(10,10,10,.9); + backdrop-filter: blur(14px) saturate(1.2); + -webkit-backdrop-filter: blur(14px) saturate(1.2); + border-bottom: 1px solid var(--border); +} +.d4rk-nav-logo { + font-family: var(--display); font-size: 1.5rem; letter-spacing: .15em; + color: var(--neon); text-decoration: none; line-height: 1; +} +.d4rk-nav-logo span { color: var(--neon2); } +.d4rk-nav a:not(.d4rk-nav-logo) { + position: relative; + font-family: var(--mono); font-size: .7rem; + letter-spacing: .25em; text-transform: uppercase; + color: var(--muted); text-decoration: none; padding: .3rem 0; + transition: color .2s; +} +.d4rk-nav a:not(.d4rk-nav-logo)::after { + content: ''; position: absolute; left: 0; right: 0; bottom: -2px; height: 2px; + background: linear-gradient(90deg, var(--neon), var(--neon2)); border-radius: 2px; + transform: scaleX(0); transform-origin: left; transition: transform .25s ease; +} +.d4rk-nav a:not(.d4rk-nav-logo):hover { color: var(--text); } +.d4rk-nav a:not(.d4rk-nav-logo):hover::after { transform: scaleX(1); } +.d4rk-nav-spacer { flex: 1; } +@media (max-width: 640px) { + .d4rk-nav { padding: .8rem 1rem; gap: 1rem; } +} +`); + }); + + app.get('/brand-nav.js', async (request, reply) => { + const hub = publicUrl(); + const links = [ + { name: 'Hub', url: hub }, + { name: 'Devlog', url: `${hub}/devlogs` }, + { name: 'Roadmap', url: `${hub}/roadmap` }, + ...listServices().map((s) => ({ name: s.name, url: s.url })), + ]; + const [first, ...rest] = brandName(); + + return reply + .type('application/javascript; charset=utf-8') + .header('cache-control', `public, max-age=${CACHE_SECONDS}`) + .header('access-control-allow-origin', '*') + .send(`// D4RKST3R-Navigation — erzeugt von ${hub} +(function () { + var links = ${JSON.stringify(links)}; + function build() { + if (document.querySelector('.d4rk-nav')) return; + var nav = document.createElement('header'); + nav.className = 'd4rk-nav'; + + var logo = document.createElement('a'); + logo.className = 'd4rk-nav-logo'; + logo.href = ${js(hub)}; + logo.innerHTML = ${js(`${first}${rest.slice(0, -1).join('')}`)} + '' + ${js(rest.slice(-1).join(''))} + ''; + nav.appendChild(logo); + + links.forEach(function (link) { + var a = document.createElement('a'); + a.href = link.url; + a.textContent = link.name; + // aktuelle Seite hervorheben + if (location.href.indexOf(link.url) === 0) a.style.color = 'var(--neon)'; + nav.appendChild(a); + }); + + document.body.insertBefore(nav, document.body.firstChild); + document.body.classList.add('d4rk-body'); + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', build); + } else { + build(); + } +})(); +`); + }); +} diff --git a/src/web/server.js b/src/web/server.js index b4a6a99..9a809dd 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -17,6 +17,7 @@ import { registerApiRoutes } from './api.js'; import { registerApiV1 } from './api-v1.js'; import { registerDevlogEndpoint } from './devlog-endpoint.js'; import { registerSsoRoutes } from './sso.js'; +import { registerBrandRoutes } from './brand.js'; import { imagesDir } from '../bot/devlog-archive.js'; // Gebautes React-Frontend (frontend/dist) — im Container immer vorhanden, @@ -69,6 +70,7 @@ export async function startWebServer(client) { registerApiV1(app, client); registerDevlogEndpoint(app, client); registerSsoRoutes(app, client); + registerBrandRoutes(app); // Healthcheck (für Portainer/NPM) app.get('/health', async () => ({ status: 'ok' }));