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
+11
View File
@@ -11,6 +11,7 @@ import { registerExtras } from './extras.js';
import { handleApplyButton, handleApplyModal, handleReviewButton } from './app-forms.js';
import { closeTicketWithTranscript } from './tickets.js';
import { handleRoleMenuButton } from './role-menus.js';
import { handleGiveawayAdminButton } from './giveaways.js';
import {
addPlaytester, removePlaytester, isWish, bumpWish,
getGiveaway, toggleGiveawayEntry, giveawayEntries,
@@ -251,6 +252,16 @@ export async function startBot() {
return;
}
// 🔁/👥-Buttons am Gewinner-Post: Reroll (Admin) + Teilnehmerliste
if (interaction.isButton() && /^(greroll|glist):/.test(interaction.customId)) {
try {
await handleGiveawayAdminButton(interaction);
} catch (error) {
console.error('[giveaway] Admin-Button fehlgeschlagen:', error);
}
return;
}
// 🎉-Button: Giveaway-Teilnahme togglen
if (interaction.isButton() && interaction.customId === 'giveaway_enter') {
const g = getGiveaway(interaction.message.id);
+51 -3
View File
@@ -1,6 +1,7 @@
// Giveaway-Ziehung: Minuten-Scheduler beendet fällige Verlosungen automatisch
import { EmbedBuilder } from 'discord.js';
import { dueGiveaways, markGiveawayEnded, giveawayEntries } from '../db.js';
// Giveaway-Ziehung: Minuten-Scheduler beendet fällige Verlosungen automatisch,
// danach gibt's am Gewinner-Post einen Reroll- und Teilnehmer-Button (Admin).
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageFlags, PermissionFlagsBits } from 'discord.js';
import { dueGiveaways, markGiveawayEnded, giveawayEntries, getGiveaway } from '../db.js';
import { brandFooter } from '../runtime-settings.js';
const CHECK_INTERVAL_MS = 60 * 1000;
@@ -50,6 +51,7 @@ export async function giveawayTick(client) {
await channel.send({
content: `🎉 **${g.prize}** geht an: ${mentions} — Glückwunsch! 🏆`,
allowedMentions: { users: winners },
components: [giveawayAdminRow(g.message_id)],
});
}
console.log(`[giveaway] '${g.prize}' beendet (${entries.length} Teilnahmen)`);
@@ -59,6 +61,52 @@ export async function giveawayTick(client) {
}
}
/** Admin-Buttons unterm Gewinner-Post: neu auslosen + Teilnehmer ansehen */
function giveawayAdminRow(giveawayId) {
return new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId(`greroll:${giveawayId}`).setStyle(ButtonStyle.Secondary).setLabel('Neu auslosen').setEmoji('🔁'),
new ButtonBuilder().setCustomId(`glist:${giveawayId}`).setStyle(ButtonStyle.Secondary).setLabel('Teilnehmer').setEmoji('👥')
);
}
/** greroll:/glist:-Buttons — Reroll nur für Admins, Liste ephemeral */
export async function handleGiveawayAdminButton(interaction) {
const [kind, giveawayId] = interaction.customId.split(':');
const g = getGiveaway(giveawayId);
if (!g) {
await interaction.reply({ content: '❌ Giveaway nicht mehr gefunden.', flags: MessageFlags.Ephemeral });
return;
}
const entries = giveawayEntries(giveawayId);
if (kind === 'glist') {
const list = entries.slice(0, 50).map((id) => `<@${id}>`).join(', ');
await interaction.reply({
content: entries.length
? `👥 **${entries.length} Teilnahmen** an **${g.prize}**:\n${list}${entries.length > 50 ? ' …' : ''}`
: `👥 Keine Teilnahmen an **${g.prize}**.`,
flags: MessageFlags.Ephemeral,
allowedMentions: { parse: [] },
});
return;
}
// Reroll: nur Admins
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
await interaction.reply({ content: '❌ Neu auslosen dürfen nur Admins.', flags: MessageFlags.Ephemeral });
return;
}
if (entries.length === 0) {
await interaction.reply({ content: '❌ Keine Teilnahmen — nichts zum Auslosen.', flags: MessageFlags.Ephemeral });
return;
}
const [winner] = drawWinners(entries, 1);
await interaction.reply({
content: `🔁 Neu ausgelost: **${g.prize}** geht jetzt an <@${winner}> — Glückwunsch! 🏆`,
allowedMentions: { users: [winner] },
});
}
export function startGiveaways(client) {
setInterval(() => giveawayTick(client).catch((e) => console.error('[giveaway]', e)), CHECK_INTERVAL_MS);
}
+11 -1
View File
@@ -3,7 +3,7 @@
// Down/Up-Alerts mit 2-Fails-Schwelle. Verwaltet im Setup-Tab "Server".
import { ActivityType, EmbedBuilder, Events } from 'discord.js';
import { GameDig } from 'gamedig';
import { getSetting, listGameservers, setGameserverMessage } from '../db.js';
import { getSetting, listGameservers, setGameserverMessage, recordPlayerSample, prunePlayerHistory } from '../db.js';
import { brandFooter, botStatusText } from '../runtime-settings.js';
import { brandEmbed } from '../embeds.js';
@@ -25,6 +25,9 @@ export const GAME_ICONS = {
// Alert-Zustand pro Server (in-memory)
const alertState = new Map(); // id → { fails, down, since }
// Letztes Query-Ergebnis pro Server — für die öffentliche /server-Seite
export const lastResults = new Map(); // id → Query-Ergebnis + checkedAt
/** Einen Server abfragen — fivem/http direkt, alles andere über gamedig */
export async function queryServer(server) {
const base = {
@@ -155,6 +158,13 @@ export async function monitorTick(client) {
const results = await Promise.all(servers.map(queryServer));
// Verlauf für die Web-Seite festhalten + Cache fürs API
for (const r of results) {
lastResults.set(r.id, { ...r, checkedAt: Date.now() });
try { recordPlayerSample(r.id, r.online, r.players); } catch { /* History ist nice-to-have */ }
}
try { prunePlayerHistory(); } catch { /* dito */ }
// Bot-Presence: Gesamtspielerzahl — nur wenn kein eigener Status (Brand-Tab) gesetzt ist
const withPlayers = results.filter((r) => r.online && r.players != null);
const totalPlayers = withPlayers.reduce((sum, r) => sum + r.players, 0);
+47
View File
@@ -474,6 +474,30 @@ export const webAdminScopes = (userId) =>
export const listWebAdmins = () => listWebAdminsStmt.all();
export const deleteWebAdmin = (userId) => deleteWebAdminStmt.run(userId).changes > 0;
// Audit-Log: wer (Owner/Team) hat wann was im Webinterface geändert
db.exec(`
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL DEFAULT (datetime('now')),
user_id TEXT NOT NULL,
username TEXT,
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT ''
);
`);
const insertAudit = db.prepare(
'INSERT INTO audit_log (user_id, username, action, detail) VALUES (?, ?, ?, ?)'
);
const listAuditStmt = db.prepare('SELECT * FROM audit_log ORDER BY id DESC LIMIT ?');
const pruneAuditStmt = db.prepare(`
DELETE FROM audit_log WHERE id NOT IN (SELECT id FROM audit_log ORDER BY id DESC LIMIT 500)
`);
export function logAudit(user, action, detail = '') {
insertAudit.run(user?.id ?? '?', user?.username ?? '?', action, String(detail).slice(0, 300));
pruneAuditStmt.run();
}
export const listAudit = (limit = 100) => listAuditStmt.all(limit);
// Game-Server (DiscordGSM-Stil): strukturierte Server statt CSV-Setting
db.exec(`
CREATE TABLE IF NOT EXISTS gameservers (
@@ -528,6 +552,29 @@ export const listGameservers = () => listGameserversStmt.all();
export const getGameserver = (id) => getGameserverStmt.get(id) ?? null;
export const deleteGameserver = (id) => deleteGameserverStmt.run(id).changes > 0;
// Spielerzahl-Verlauf für die öffentliche /server-Seite (Sample alle 2 min, 7 Tage Retention)
db.exec(`
CREATE TABLE IF NOT EXISTS player_history (
server_id INTEGER NOT NULL,
ts TEXT NOT NULL DEFAULT (datetime('now')),
online INTEGER NOT NULL,
players INTEGER
);
CREATE INDEX IF NOT EXISTS idx_ph_server_ts ON player_history (server_id, ts);
`);
const insertPlayerSample = db.prepare(
'INSERT INTO player_history (server_id, online, players) VALUES (?, ?, ?)'
);
const playerHistoryStmt = db.prepare(`
SELECT ts, online, players FROM player_history
WHERE server_id = ? AND ts >= datetime('now', ?) ORDER BY ts
`);
const prunePlayerHistoryStmt = db.prepare(`DELETE FROM player_history WHERE ts < datetime('now', '-7 days')`);
export const recordPlayerSample = (serverId, online, players) =>
insertPlayerSample.run(serverId, online ? 1 : 0, players);
export const playerHistory = (serverId, hours = 24) => playerHistoryStmt.all(serverId, `-${hours} hours`);
export const prunePlayerHistory = () => prunePlayerHistoryStmt.run();
// Moderation: Verwarnungen + Sticky-Roles
db.exec(`
CREATE TABLE IF NOT EXISTS warns (
+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 };
});