UX-Paket: Navbar aufgeräumt, /server-Seite, Lightbox, Skeletons, Audit-Log, Reroll

- Navbar entschlackt: nur noch Devlog · Roadmap · Server · Community ·
  Setup; Galerie/Level/Events/Changelog im Community-Dropdown,
  Profil/Commits/Logout im Avatar-Menü; Mobile-Burger mit Drawer
- Öffentliche /server-Seite: Live-Status-Karten mit Spieler-Balken,
  Map, Connect-Button und 24h-Spielerzahl-Chart (SVG); Monitor sampelt
  jede Abfrage in player_history (7 Tage Retention), GET /api/servers
  liefert nur öffentliche Felder (keine Query-URLs/Hosts)
- Lightbox: Galerie- und Devlog-Bilder öffnen im Overlay mit
  Pfeiltasten, Buttons, Wisch-Gesten und Zähler statt neuem Tab
- Skeleton-Loader: schimmernde Platzhalter auf Devlogs, Roadmap,
  Galerie, Level, Changelog und Server statt "Lade …"
- Team-Audit-Log: audit_log-Tabelle (max 500 Einträge), Logging bei
  Settings/Composer/Vorlagen/Rollen-Menüs/Gameservern/Team/API-Keys/
  Branding, GET /api/auditlog (Owner) + Liste im Team-Tab
- Giveaway: 🔁 Reroll-Button (Admin-only) + 👥 Teilnehmerliste am
  Gewinner-Post

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 22:15:58 +02:00
co-authored by Claude Fable 5
parent 8d470ba26b
commit be11398f7c
17 changed files with 671 additions and 28 deletions
+45
View File
@@ -14,7 +14,9 @@ import {
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
saveTemplate, listTemplates, deleteTemplate,
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
logAudit, listAudit, playerHistory,
} from '../db.js';
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
import { sanitizeEmbed } from './api-v1.js';
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
@@ -122,6 +124,29 @@ export function registerApiRoutes(app, client) {
});
app.get('/api/heatmap', async () => ({ days: commitHeatmap() }));
// Öffentliche Server-Status-Seite: letzte Query-Ergebnisse + 24h-Verlauf
// (bewusst ohne query_url/host/port — nur was Besucher sehen dürfen)
app.get('/api/servers', async () => ({
servers: listGameservers().map((s) => {
const r = lastResults.get(s.id);
return {
id: s.id,
name: s.name,
type: s.type,
icon: GAME_ICONS[s.type] ?? '🎮',
address: s.address || null,
connect_url: s.connect_url || null,
online: r?.online ?? null, // null = noch nie gecheckt
players: r?.players ?? null,
max: r?.max ?? null,
map: r?.map ?? null,
ping: r?.ping ?? null,
checkedAt: r?.checkedAt ?? null,
history: playerHistory(s.id, 24),
};
}),
}));
// Level-Bestenliste + Server-Aktivität — öffentlich
app.get('/api/levels', async () => ({ levels: topLevels(50) }));
app.get('/api/serverstats', async () => ({
@@ -515,6 +540,7 @@ ${rssItems}
}
request.log.info('Settings per Web-UI aktualisiert');
logAudit(getSessionUser(request), 'settings', Object.keys(body).join(', ').slice(0, 300));
return { ok: true, settings: currentSettings() };
});
@@ -581,6 +607,7 @@ ${rssItems}
if (!menu) return;
const id = createRoleMenu(menu);
request.log.info(`Rollen-Menü ${id} erstellt`);
logAudit(getSessionUser(request), 'rollen-menü erstellt', menu.title);
return { ok: true, menu: menuToJson(getRoleMenu(id)) };
});
@@ -601,6 +628,7 @@ ${rssItems}
request.log.warn(`Menü ${id}: Discord-Update fehlgeschlagen: ${error.message}`);
}
}
logAudit(getSessionUser(request), 'rollen-menü geändert', menu.title);
return { ok: true, published, menu: menuToJson(getRoleMenu(id)) };
});
@@ -622,6 +650,7 @@ ${rssItems}
await unpublishRoleMenu(client, menu);
deleteRoleMenu(menu.id);
request.log.info(`Rollen-Menü ${menu.id} gelöscht`);
logAudit(getSessionUser(request), 'rollen-menü gelöscht', menu.title);
return { deleted: true };
});
@@ -802,6 +831,7 @@ ${rssItems}
const server = parseServerBody(request, reply);
if (!server) return;
const id = createGameserver(server);
logAudit(getSessionUser(request), 'server hinzugefügt', server.name);
return { ok: true, server: getGameserver(id) };
});
@@ -812,6 +842,7 @@ ${rssItems}
const server = parseServerBody(request, reply);
if (!server) return;
updateGameserver({ ...server, id });
logAudit(getSessionUser(request), 'server geändert', server.name);
return { ok: true, server: getGameserver(id) };
});
@@ -827,6 +858,7 @@ ${rssItems}
await message?.delete().catch(() => {});
}
deleteGameserver(server.id);
logAudit(getSessionUser(request), 'server gelöscht', server.name);
return { deleted: true };
});
@@ -964,11 +996,13 @@ ${rssItems}
}
await message.edit(payload);
request.log.info(`Composer: Nachricht ${messageId} bearbeitet`);
logAudit(getSessionUser(request), 'composer bearbeitet', `#${channel.name ?? channelId}`);
return { ok: true, edited: true, message_id: String(messageId) };
}
const message = await channel.send(payload);
request.log.info(`Composer: Nachricht ${message.id} gesendet`);
logAudit(getSessionUser(request), 'composer gesendet', `#${channel.name ?? channelId}`);
return { ok: true, edited: false, message_id: message.id };
});
@@ -992,6 +1026,7 @@ ${rssItems}
if (target === 'avatar') await client.user.setAvatar(buffer);
else await client.user.setBanner(buffer);
request.log.info(`Bot-${target} aktualisiert (${(buffer.length / 1024).toFixed(0)} KB)`);
logAudit(getSessionUser(request), 'branding', `bot-${target} geändert`);
return { ok: true };
} catch (error) {
request.log.error({ err: error }, `Bot-${target} fehlgeschlagen`);
@@ -1019,6 +1054,7 @@ ${rssItems}
return reply.code(400).send({ error: 'Vorlage ist leer' });
}
saveTemplate(name, payload);
logAudit(getSessionUser(request), 'vorlage gespeichert', name);
return { ok: true, templates: listTemplates() };
});
@@ -1210,14 +1246,22 @@ ${rssItems}
}
saveWebAdmin(userId, username, scopes);
request.log.info(`Team-Mitglied ${username} (${userId}): ${scopes.join(',')}`);
logAudit(getSessionUser(request), 'team geändert', `${username}: ${scopes.join(',')}`);
return { ok: true, admins: listWebAdmins() };
});
app.delete('/api/webadmins/:id', async (request, reply) => {
if (requireAdmin(request, reply)) return;
logAudit(getSessionUser(request), 'team entfernt', String(request.params.id));
return { deleted: deleteWebAdmin(String(request.params.id)), admins: listWebAdmins() };
});
// Audit-Log: die letzten Team-/Owner-Aktionen im Webinterface (nur Owner)
app.get('/api/auditlog', async (request, reply) => {
if (requireAdmin(request, reply)) return;
return { entries: listAudit(100) };
});
// --- API-Keys (Admin) — für /api/v1/* ---
const VALID_SCOPES = ['message', 'dm', 'roles', 'read'];
@@ -1237,6 +1281,7 @@ ${rssItems}
}
const { id, key } = createApiKey(name, scopes);
request.log.info(`API-Key '${name}' erstellt (Scopes: ${scopes.join(',')})`);
logAudit(getSessionUser(request), 'api-key erstellt', name);
// Klartext-Key nur in dieser einen Antwort!
return { id, key, name, scopes };
});