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:
@@ -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 |
|
||||
|
||||
+26
-1
@@ -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
|
||||
<link rel="stylesheet" href="https://bot.d4rkst3r.de/brand.css">
|
||||
<script defer src="https://bot.d4rkst3r.de/brand-nav.js"></script>
|
||||
```
|
||||
|
||||
* **`/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
|
||||
|
||||
@@ -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() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{services.length > 0 && (
|
||||
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
|
||||
<h2 className="settings-title">// Dienste</h2>
|
||||
<div className="home-tiles">
|
||||
{services.map((s) => (
|
||||
<a className="card home-tile" href={s.url} target="_blank" rel="noreferrer" key={s.id}>
|
||||
<span className="home-tile-icon">{s.icon}</span>
|
||||
<span className="home-tile-title">{s.name} <span className="home-tile-ext">↗</span></span>
|
||||
{s.description && <span className="home-tile-desc">{s.description}</span>}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{latest && (
|
||||
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
|
||||
<h2 className="settings-title">// Neuestes Devlog</h2>
|
||||
|
||||
@@ -161,6 +161,10 @@ export default function Settings({ me }) {
|
||||
const [botDesc, setBotDesc] = useState('');
|
||||
const [team, setTeam] = useState([]);
|
||||
const [audit, setAudit] = useState([]);
|
||||
// Portal-Dienste (Kacheln auf der Startseite)
|
||||
const emptyService = { id: null, name: '', url: '', icon: '', description: '', sort: 0 };
|
||||
const [services, setServices] = useState([]);
|
||||
const [serviceDraft, setServiceDraft] = useState(emptyService);
|
||||
// SSO-Apps (Fremd-Dienste mit Discord-Login)
|
||||
const [ssoApps, setSsoApps] = useState([]);
|
||||
const [ssoDraft, setSsoDraft] = useState({ slug: '', name: '', redirect_prefix: '' });
|
||||
@@ -208,6 +212,7 @@ export default function Settings({ me }) {
|
||||
apiGet('/api/webadmins').then((d) => setTeam(d.admins)).catch(() => {});
|
||||
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(() => {});
|
||||
}, [me.admin, me.scopes?.length]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -241,6 +246,28 @@ export default function Settings({ me }) {
|
||||
if (res) setTeam(res.admins);
|
||||
}
|
||||
|
||||
async function saveService() {
|
||||
if (!serviceDraft.name.trim() || !serviceDraft.url.trim()) {
|
||||
flash('✗ Name und URL nötig');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = serviceDraft.id
|
||||
? await apiPut(`/api/services/${serviceDraft.id}`, serviceDraft)
|
||||
: await apiPost('/api/services', serviceDraft);
|
||||
setServices(res.services);
|
||||
setServiceDraft(emptyService);
|
||||
flash('✓ Dienst gespeichert');
|
||||
} catch {
|
||||
flash('✗ Speichern fehlgeschlagen (URL prüfen)');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeService(id) {
|
||||
const res = await apiDelete(`/api/services/${id}`).catch(() => null);
|
||||
if (res) setServices(res.services);
|
||||
}
|
||||
|
||||
async function createSsoApp() {
|
||||
if (!ssoDraft.slug.trim() || !ssoDraft.name.trim() || !ssoDraft.redirect_prefix.trim()) {
|
||||
flash('✗ Kürzel, Name und Rücksprung-URL nötig');
|
||||
@@ -1246,6 +1273,42 @@ export default function Settings({ me }) {
|
||||
</div>
|
||||
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Dienste (Portal)</h2>
|
||||
<p className="section-intro">
|
||||
Erscheinen als Kacheln auf der Startseite — z. B. Gitea, Kanban oder die Cloud.
|
||||
</p>
|
||||
{services.length > 0 && (
|
||||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||||
{services.map((s) => (
|
||||
<div className="key-row" key={s.id}>
|
||||
<span className="key-name">{s.icon} {s.name}</span>
|
||||
<span className="key-used" title={s.url}>{s.url}</span>
|
||||
<button className="btn btn-mini" onClick={() => setServiceDraft({
|
||||
id: s.id, name: s.name, url: s.url, icon: s.icon,
|
||||
description: s.description ?? '', sort: s.sort ?? 0,
|
||||
})}>Bearbeiten</button>
|
||||
<button className="card-delete" title="Dienst entfernen" onClick={() => removeService(s.id)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<label>{serviceDraft.id ? `Dienst #${serviceDraft.id} bearbeiten` : 'Neuer Dienst'}</label>
|
||||
<div className="field-row">
|
||||
<input type="text" style={{ flex: 'none', width: '4rem' }} placeholder="🗂️" value={serviceDraft.icon} onChange={(e) => setServiceDraft({ ...serviceDraft, icon: e.target.value })} />
|
||||
<input type="text" style={{ flex: 'none', width: '10rem' }} placeholder="Kanban" value={serviceDraft.name} onChange={(e) => setServiceDraft({ ...serviceDraft, name: e.target.value })} />
|
||||
<input type="text" placeholder="https://kanban.d4rkst3r.de" value={serviceDraft.url} onChange={(e) => setServiceDraft({ ...serviceDraft, url: e.target.value })} />
|
||||
<input type="number" style={{ flex: 'none', width: '5rem' }} placeholder="Pos." value={serviceDraft.sort} onChange={(e) => setServiceDraft({ ...serviceDraft, sort: e.target.value })} />
|
||||
</div>
|
||||
<div className="field-row" style={{ marginTop: '.6rem' }}>
|
||||
<input type="text" placeholder="Kurzbeschreibung für die Kachel" value={serviceDraft.description} onChange={(e) => setServiceDraft({ ...serviceDraft, description: e.target.value })} />
|
||||
<button className="btn btn-save" onClick={saveService}>{serviceDraft.id ? 'Aktualisieren' : 'Hinzufügen'}</button>
|
||||
{serviceDraft.id && <button className="btn btn-ghost" onClick={() => setServiceDraft(emptyService)}>Abbrechen</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Impressum & Datenschutz</h2>
|
||||
<p className="field-hint" style={{ marginBottom: '.9rem' }}>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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