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 |
|
| 🎮 **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 |
|
| 👥 **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) |
|
| 🔑 **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) |
|
| ⚖️ **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 |
|
| 💡 **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 |
|
| 🎉 **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 |
|
| `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 /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 /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 /api/legal` · `DELETE /api/profile` | — / Member | Impressums-Angaben · DSGVO-Löschung |
|
||||||
| `GET/PUT /api/settings`, `POST /api/settings/test/:target` | Admin | Setup-Seite |
|
| `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 |
|
| `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.
|
* 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
|
* 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 [servers, setServers] = useState(null);
|
||||||
const [latest, setLatest] = useState(null);
|
const [latest, setLatest] = useState(null);
|
||||||
const [invite, setInvite] = useState(null);
|
const [invite, setInvite] = useState(null);
|
||||||
|
const [services, setServices] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiGet('/api/serverstats').then(setStats).catch(() => {});
|
apiGet('/api/serverstats').then(setStats).catch(() => {});
|
||||||
apiGet('/api/servers').then((d) => setServers(d.servers)).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/devlogs?page=1').then((d) => setLatest(d.items?.[0] ?? null)).catch(() => {});
|
||||||
apiGet('/api/invite').then((d) => setInvite(d.invite)).catch(() => {});
|
apiGet('/api/invite').then((d) => setInvite(d.invite)).catch(() => {});
|
||||||
|
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -95,6 +97,21 @@ export default function Home() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 && (
|
{latest && (
|
||||||
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
|
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
|
||||||
<h2 className="settings-title">// Neuestes Devlog</h2>
|
<h2 className="settings-title">// Neuestes Devlog</h2>
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ export default function Settings({ me }) {
|
|||||||
const [botDesc, setBotDesc] = useState('');
|
const [botDesc, setBotDesc] = useState('');
|
||||||
const [team, setTeam] = useState([]);
|
const [team, setTeam] = useState([]);
|
||||||
const [audit, setAudit] = 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)
|
// SSO-Apps (Fremd-Dienste mit Discord-Login)
|
||||||
const [ssoApps, setSsoApps] = useState([]);
|
const [ssoApps, setSsoApps] = useState([]);
|
||||||
const [ssoDraft, setSsoDraft] = useState({ slug: '', name: '', redirect_prefix: '' });
|
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/webadmins').then((d) => setTeam(d.admins)).catch(() => {});
|
||||||
apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {});
|
apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {});
|
||||||
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
|
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
|
||||||
|
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
|
||||||
}, [me.admin, me.scopes?.length]);
|
}, [me.admin, me.scopes?.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -241,6 +246,28 @@ export default function Settings({ me }) {
|
|||||||
if (res) setTeam(res.admins);
|
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() {
|
async function createSsoApp() {
|
||||||
if (!ssoDraft.slug.trim() || !ssoDraft.name.trim() || !ssoDraft.redirect_prefix.trim()) {
|
if (!ssoDraft.slug.trim() || !ssoDraft.name.trim() || !ssoDraft.redirect_prefix.trim()) {
|
||||||
flash('✗ Kürzel, Name und Rücksprung-URL nötig');
|
flash('✗ Kürzel, Name und Rücksprung-URL nötig');
|
||||||
@@ -1246,6 +1273,42 @@ export default function Settings({ me }) {
|
|||||||
</div>
|
</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">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Impressum & Datenschutz</h2>
|
<h2 className="settings-title">// Impressum & Datenschutz</h2>
|
||||||
<p className="field-hint" style={{ marginBottom: '.9rem' }}>
|
<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 { border-color: rgba(242, 63, 67, .5); color: #f23f43; }
|
||||||
.btn-danger:hover { border-color: #f23f43; background: rgba(242, 63, 67, .1); }
|
.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;
|
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
|
// SSO: Fremd-Apps (Kanban, Platform …), die den Discord-Login des Bots mitnutzen
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS sso_apps (
|
CREATE TABLE IF NOT EXISTS sso_apps (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
||||||
logAudit, listAudit, playerHistory, deleteUserData,
|
logAudit, listAudit, playerHistory, deleteUserData,
|
||||||
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
|
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
|
||||||
|
createService, updateService, listServices, deleteService,
|
||||||
} from '../db.js';
|
} from '../db.js';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
|
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
|
// Level-Bestenliste + Server-Aktivität — öffentlich
|
||||||
app.get('/api/levels', async () => ({ levels: topLevels(50) }));
|
app.get('/api/levels', async () => ({ levels: topLevels(50) }));
|
||||||
app.get('/api/serverstats', async () => ({
|
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 { registerApiV1 } from './api-v1.js';
|
||||||
import { registerDevlogEndpoint } from './devlog-endpoint.js';
|
import { registerDevlogEndpoint } from './devlog-endpoint.js';
|
||||||
import { registerSsoRoutes } from './sso.js';
|
import { registerSsoRoutes } from './sso.js';
|
||||||
|
import { registerBrandRoutes } from './brand.js';
|
||||||
import { imagesDir } from '../bot/devlog-archive.js';
|
import { imagesDir } from '../bot/devlog-archive.js';
|
||||||
|
|
||||||
// Gebautes React-Frontend (frontend/dist) — im Container immer vorhanden,
|
// Gebautes React-Frontend (frontend/dist) — im Container immer vorhanden,
|
||||||
@@ -69,6 +70,7 @@ export async function startWebServer(client) {
|
|||||||
registerApiV1(app, client);
|
registerApiV1(app, client);
|
||||||
registerDevlogEndpoint(app, client);
|
registerDevlogEndpoint(app, client);
|
||||||
registerSsoRoutes(app, client);
|
registerSsoRoutes(app, client);
|
||||||
|
registerBrandRoutes(app);
|
||||||
|
|
||||||
// Healthcheck (für Portainer/NPM)
|
// Healthcheck (für Portainer/NPM)
|
||||||
app.get('/health', async () => ({ status: 'ok' }));
|
app.get('/health', async () => ({ status: 'ok' }));
|
||||||
|
|||||||
Reference in New Issue
Block a user