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:
@@ -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 () => ({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
`);
|
||||
});
|
||||
}
|
||||
@@ -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' }));
|
||||
|
||||
Reference in New Issue
Block a user