Portal-Dienste und geteiltes Design-System für die anderen Apps

- Dienste (Gitea, Kanban, Cloud …) werden im System-Tab gepflegt und
  erscheinen als Kacheln auf der Startseite; GET /api/services ist
  öffentlich, Pflege braucht den settings-Scope
- /brand.css liefert die Design-Tokens (Farben live aus dem Brand-Tab,
  ändern sich damit überall mit) plus Basis-Klassen d4rk-card,
  d4rk-btn, d4rk-title, d4rk-tag
- /brand-nav.js baut die D4RKST3R-Leiste in jede fremde App ein, mit
  Links zum Hub und zu allen gepflegten Diensten; die Links stecken
  fertig im Skript, dadurch kein zweiter Request und kein CORS nötig
- Beide Dateien mit offenem CORS-Header und 5 Minuten Cache
- Doku für Anbindung + Schriften in docs/sso.md ergänzt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 13:28:50 +02:00
co-authored by Claude Fable 5
parent 70cae85064
commit c73b4e1074
9 changed files with 350 additions and 1 deletions
+46
View File
@@ -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 () => ({
+167
View File
@@ -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.
//
// <link rel="stylesheet" href="https://bot.d4rkst3r.de/brand.css">
// <script defer src="https://bot.d4rkst3r.de/brand-nav.js"></script>
//
// 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('')}`)} + '<span>' + ${js(rest.slice(-1).join(''))} + '</span>';
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();
}
})();
`);
});
}
+2
View File
@@ -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' }));