diff --git a/README.md b/README.md
index c1afcb4..066cfe3 100644
--- a/README.md
+++ b/README.md
@@ -45,9 +45,9 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
| 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) |
| 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal |
| 🎮 **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 |
+| 👥 **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 |
| 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite |
-| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung nach Ablauf |
+| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung nach Ablauf; am Gewinner-Post: 🔁 Neu auslosen (Admin) + 👥 Teilnehmerliste |
| 📈 **Contribution-Heatmap** | GitHub-Style-Jahreskalender aus dem Commit-Archiv auf der Roadmap-Seite |
| 👤 **Member-Bereich** | Discord-Login für alle Member: `/profil` (Rang + XP-Balken, Rollen, Playtester-Badge, eigener Alpha-Key), Rollen-Selfservice im Browser (nutzt die Rollen-Menüs), Wünsche einreichen + upvoten auf der Roadmap; **Member-Gate**: Login nur für Server-Mitglieder (abweisbare bekommen den Invite-Link) |
| 🏓 `/ping`, 🗄 `/devlog-backfill` | Lebenszeichen · Kanal-Historie nacharchivieren (Admin) |
@@ -63,6 +63,7 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
| `/changelog` | öffentlich | Alle Releases mit Notes, Tag- und Pre-Release-Chips |
| `/feed.xml` | öffentlich | RSS-Feed der Devlogs |
| `/profil` | Member | Eigenes Profil: Rang, XP, Rollen (togglebar), Alpha-Key |
+| `/server` | öffentlich | Live-Status aller Game-Server + 24h-Spielerzahl-Verlauf |
| `/commits` | nur Admin | Archivierte Commits aller Repos (SHA → Gitea-Link) |
| `/settings` | nur Admin | **Setup-Seite** — siehe unten |
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 54bb1b8..7cf76fd 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,5 +1,5 @@
-import { useEffect, useMemo, useState } from 'react';
-import { Routes, Route, NavLink, Navigate } from 'react-router-dom';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom';
import { apiGet } from './api.js';
import Devlogs from './pages/Devlogs.jsx';
import Changelog from './pages/Changelog.jsx';
@@ -10,6 +10,33 @@ import Events from './pages/Events.jsx';
import Commits from './pages/Commits.jsx';
import Settings from './pages/Settings.jsx';
import Profil from './pages/Profil.jsx';
+import Server from './pages/Server.jsx';
+
+const COMMUNITY_LINKS = [
+ ['/galerie', 'Galerie'],
+ ['/level', 'Level'],
+ ['/events', 'Events'],
+ ['/changelog', 'Changelog'],
+];
+
+/** Dropdown in der Navbar (schließt bei Klick daneben und bei Navigation) */
+function NavDropdown({ label, active, children }) {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+ useEffect(() => {
+ const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
+ document.addEventListener('mousedown', onDoc);
+ return () => document.removeEventListener('mousedown', onDoc);
+ }, []);
+ return (
+
+
+ {open &&
setOpen(false)}>{children}
}
+
+ );
+}
function DiscordMark() {
return (
@@ -51,6 +78,12 @@ export default function App() {
// Login-Status einmal beim Laden holen
const [me, setMe] = useState({ user: null, admin: false, scopes: [], loading: true });
const [scrolled, setScrolled] = useState(false);
+ const [drawer, setDrawer] = useState(false);
+ const location = useLocation();
+ const communityActive = COMMUNITY_LINKS.some(([to]) => location.pathname.startsWith(to));
+
+ // Drawer bei Seitenwechsel schließen
+ useEffect(() => { setDrawer(false); }, [location.pathname]);
// Member-Gate: OAuth war okay, aber User ist nicht auf dem Discord (?gate=1)
const [gated, setGated] = useState(() => new URLSearchParams(window.location.search).has('gate'));
const [invite, setInvite] = useState(null);
@@ -83,30 +116,58 @@ export default function App() {
{me.loading ? null : me.user ? (
-
- {me.user.avatar &&

}
-
{me.user.username}
-
✕
-
+
+ {me.user.avatar &&
}
+ {me.user.username}
+
+ }
+ >
+ Profil
+ {me.admin && Commits}
+ Logout
+
) : (
Login
)}
+
+
+ {drawer && (
+
+ )}
+
{gated && (
@@ -126,6 +187,7 @@ export default function App() {
} />
} />
} />
+
} />
} />
} />
} />
diff --git a/frontend/src/components/Lightbox.jsx b/frontend/src/components/Lightbox.jsx
new file mode 100644
index 0000000..27210e7
--- /dev/null
+++ b/frontend/src/components/Lightbox.jsx
@@ -0,0 +1,61 @@
+// Bild-Overlay mit Blättern (Pfeiltasten, Buttons, Wischen) statt neuem Tab.
+import { useCallback, useEffect, useState } from 'react';
+
+export default function Lightbox({ images, start = 0, onClose }) {
+ const [index, setIndex] = useState(start);
+ const [touchX, setTouchX] = useState(null);
+
+ const prev = useCallback(() => setIndex((i) => (i - 1 + images.length) % images.length), [images.length]);
+ const next = useCallback(() => setIndex((i) => (i + 1) % images.length), [images.length]);
+
+ useEffect(() => {
+ function onKey(e) {
+ if (e.key === 'Escape') onClose();
+ else if (e.key === 'ArrowLeft') prev();
+ else if (e.key === 'ArrowRight') next();
+ }
+ document.addEventListener('keydown', onKey);
+ document.body.style.overflow = 'hidden';
+ return () => {
+ document.removeEventListener('keydown', onKey);
+ document.body.style.overflow = '';
+ };
+ }, [onClose, prev, next]);
+
+ const img = images[index];
+ if (!img) return null;
+
+ return (
+
setTouchX(e.touches[0].clientX)}
+ onTouchEnd={(e) => {
+ if (touchX === null) return;
+ const dx = e.changedTouches[0].clientX - touchX;
+ if (dx > 50) prev();
+ else if (dx < -50) next();
+ setTouchX(null);
+ }}
+ role="dialog" aria-modal="true"
+ >
+
+ {images.length > 1 && (
+
+ )}
+

e.stopPropagation()}
+ />
+ {images.length > 1 && (
+
+ )}
+
e.stopPropagation()}>
+ {img.caption && {img.caption}}
+ {images.length > 1 && {index + 1} / {images.length}}
+
+
+ );
+}
diff --git a/frontend/src/components/Skeleton.jsx b/frontend/src/components/Skeleton.jsx
new file mode 100644
index 0000000..0b842b1
--- /dev/null
+++ b/frontend/src/components/Skeleton.jsx
@@ -0,0 +1,38 @@
+// Schimmernde Lade-Platzhalter statt „Lade …"-Text
+
+/** n Karten-Platzhalter (Devlogs, Roadmap, Changelog, Server …) */
+export function SkeletonCards({ n = 3 }) {
+ return (
+
+ {Array.from({ length: n }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+/** Bild-Raster-Platzhalter (Galerie) */
+export function SkeletonGrid({ n = 6 }) {
+ return (
+
+ {Array.from({ length: n }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+/** Zeilen-Platzhalter (Level-Liste u. Ä.) */
+export function SkeletonRows({ n = 8 }) {
+ return (
+
+ {Array.from({ length: n }, (_, i) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/pages/Changelog.jsx b/frontend/src/pages/Changelog.jsx
index 27d17e9..a6fed42 100644
--- a/frontend/src/pages/Changelog.jsx
+++ b/frontend/src/pages/Changelog.jsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
+import { SkeletonCards } from '../components/Skeleton.jsx';
import { Markdown } from '../markdown.jsx';
const dateFmt = new Intl.DateTimeFormat('de-DE', {
@@ -33,7 +34,7 @@ export default function Changelog() {
{error && Changelog konnte nicht geladen werden.
}
- {!error && !data && Lade …
}
+ {!error && !data && }
{data?.total === 0 && Noch keine Releases veröffentlicht.
}
{data?.total > 0 &&
diff --git a/frontend/src/pages/Devlogs.jsx b/frontend/src/pages/Devlogs.jsx
index c6a7d66..75d3a74 100644
--- a/frontend/src/pages/Devlogs.jsx
+++ b/frontend/src/pages/Devlogs.jsx
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useState } from 'react';
import { apiGet, apiDelete } from '../api.js';
import { Markdown } from '../markdown.jsx';
+import Lightbox from '../components/Lightbox.jsx';
+import { SkeletonCards } from '../components/Skeleton.jsx';
const dateFmt = new Intl.DateTimeFormat('de-DE', {
weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric',
@@ -46,6 +48,7 @@ export default function Devlogs({ me }) {
const [error, setError] = useState(false);
const [qInput, setQInput] = useState('');
const [q, setQ] = useState('');
+ const [lightbox, setLightbox] = useState(null); // { images, start }
// Sucheingabe entprellen (300 ms), bei neuer Suche zurück auf Seite 1
useEffect(() => {
@@ -110,7 +113,7 @@ export default function Devlogs({ me }) {
{error && Devlogs konnten nicht geladen werden.
}
- {!error && !data && Lade …
}
+ {!error && !data && }
{data?.total === 0 && (
{q ? `Keine Treffer für „${q}".` : 'Noch keine Devlogs archiviert.'}
)}
@@ -147,8 +150,14 @@ export default function Devlogs({ me }) {
{d.images?.length > 0 && (
)}
+ {lightbox && (
+ setLightbox(null)}
+ />
+ )}
+
{pages > 1 && (
+
+ {audit.length > 0 && (
+ <>
+ // Audit-Log
+
+ Die letzten Aktionen im Webinterface (max. 500 werden aufbewahrt).
+
+
+ {audit.map((a) => (
+
+ {a.ts.slice(0, 16).replace('T', ' ')}
+ {a.username}
+ {a.action}
+ {a.detail}
+
+ ))}
+
+ >
+ )}
);
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 7b0f3dc..ea5366e 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -1255,3 +1255,151 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
.profile-roles { justify-content: center; }
.wish-form { flex-direction: column; }
}
+
+/* ── NAV-DROPDOWNS + BURGER ────────────────────────── */
+.nav-drop { position: relative; }
+.nav-drop-btn {
+ display: inline-flex; align-items: center; gap: .35rem;
+ background: none; border: 0; cursor: pointer;
+ font-family: var(--mono); font-size: .7rem;
+ letter-spacing: .25em; text-transform: uppercase;
+ color: var(--muted); padding: .3rem 0;
+ transition: color .2s, text-shadow .2s;
+}
+.nav-drop-btn:hover { color: var(--text); }
+.nav-drop-btn.active { color: var(--neon); text-shadow: 0 0 12px rgba(245, 197, 24, .35); }
+.nav-caret { font-size: .6rem; transition: transform .2s; }
+.nav-drop.open .nav-caret { transform: rotate(180deg); }
+.nav-drop-menu {
+ position: absolute; top: calc(100% + 10px); left: 50%; transform: translateX(-50%);
+ display: flex; flex-direction: column; min-width: 11rem;
+ background: rgba(14, 14, 14, .97); border: 1px solid var(--border); border-radius: 10px;
+ padding: .4rem; box-shadow: 0 12px 32px rgba(0, 0, 0, .6);
+ animation: riseIn .18s ease both;
+ z-index: 300;
+}
+.auth .nav-drop-menu { left: auto; right: 0; transform: none; }
+.nav-drop-menu a {
+ font-family: var(--mono); font-size: .7rem;
+ letter-spacing: .2em; text-transform: uppercase;
+ color: var(--muted); padding: .55rem .8rem; border-radius: 6px;
+ transition: color .15s, background .15s;
+}
+.nav-drop-menu a:hover { color: var(--text); background: rgba(255, 255, 255, .05); }
+.nav-drop-menu a.active { color: var(--neon); }
+.user-chip-inner { display: inline-flex; align-items: center; gap: .55rem; letter-spacing: 0; }
+.auth .nav-drop-btn {
+ border: 1px solid rgba(255, 255, 255, .1); border-radius: 999px;
+ padding: .3rem .8rem .3rem .35rem; background: rgba(255, 255, 255, .03);
+}
+.auth .nav-drop-btn:hover { border-color: var(--border); }
+
+.burger {
+ display: none; background: none; border: 1px solid var(--border); border-radius: 8px;
+ color: var(--neon); font-size: 1.1rem; line-height: 1; padding: .4rem .6rem; cursor: pointer;
+}
+.drawer {
+ position: fixed; inset: 0; top: 0; z-index: 190;
+ display: flex; flex-direction: column; gap: .2rem;
+ padding: 5.5rem 1.5rem 2rem;
+ background: rgba(8, 8, 8, .97);
+ animation: riseIn .2s ease both;
+ overflow-y: auto;
+}
+.drawer a {
+ font-family: var(--mono); font-size: .95rem;
+ letter-spacing: .25em; text-transform: uppercase;
+ color: var(--muted); padding: .85rem .6rem;
+ border-bottom: 1px solid rgba(255, 255, 255, .05);
+ transition: color .15s;
+}
+.drawer a:hover, .drawer a.active { color: var(--neon); }
+@media (max-width: 760px) {
+ .nav-links, .auth { display: none; }
+ .burger { display: block; margin-left: auto; }
+}
+
+/* ── LIGHTBOX ──────────────────────────────────────── */
+.lightbox {
+ position: fixed; inset: 0; z-index: 1000;
+ display: flex; align-items: center; justify-content: center;
+ background: rgba(0, 0, 0, .92);
+ animation: lbIn .18s ease both;
+}
+@keyframes lbIn { from { opacity: 0; } to { opacity: 1; } }
+.lb-img {
+ max-width: min(92vw, 1400px); max-height: 84vh;
+ border: 1px solid var(--border); border-radius: 8px;
+ box-shadow: 0 24px 80px rgba(0, 0, 0, .8);
+}
+.lb-close {
+ position: absolute; top: 1.2rem; right: 1.4rem;
+ background: none; border: 1px solid var(--border); border-radius: 999px;
+ color: var(--text); font-size: 1rem; padding: .45rem .7rem; cursor: pointer;
+ transition: color .15s, border-color .15s;
+}
+.lb-close:hover { color: var(--neon2); border-color: var(--neon2); }
+.lb-nav {
+ position: absolute; top: 50%; transform: translateY(-50%);
+ background: rgba(0, 0, 0, .5); border: 1px solid var(--border); border-radius: 999px;
+ color: var(--text); font-size: 1.8rem; line-height: 1;
+ width: 3rem; height: 3rem; cursor: pointer;
+ transition: color .15s, border-color .15s;
+}
+.lb-nav:hover { color: var(--neon); border-color: var(--neon); }
+.lb-prev { left: 1.2rem; }
+.lb-next { right: 1.2rem; }
+.lb-meta {
+ position: absolute; bottom: 1.4rem; left: 50%; transform: translateX(-50%);
+ display: flex; gap: 1.2rem; align-items: baseline;
+ font-family: var(--mono); font-size: .72rem; letter-spacing: .12em;
+ color: var(--muted); background: rgba(0, 0, 0, .6);
+ border: 1px solid rgba(255, 255, 255, .08); border-radius: 999px;
+ padding: .4rem 1rem; white-space: nowrap;
+}
+.lb-count { color: var(--neon); }
+
+/* ── SKELETON-LOADER ───────────────────────────────── */
+.skel {
+ position: relative; overflow: hidden;
+ background: rgba(255, 255, 255, .05); border-radius: 8px;
+}
+.skel::after {
+ content: '';
+ position: absolute; inset: 0;
+ background: linear-gradient(90deg, transparent, rgba(245, 197, 24, .07), transparent);
+ transform: translateX(-100%);
+ animation: skelShine 1.4s ease-in-out infinite;
+}
+@keyframes skelShine { to { transform: translateX(100%); } }
+.skel-card { animation: riseIn .3s ease both; margin-bottom: 1.1rem; }
+.skel-line { height: .95rem; margin: .55rem 0; }
+.skel-shot { aspect-ratio: 16 / 9; border-radius: 10px; animation: riseIn .3s ease both; }
+.skel-row { height: 2.6rem; border-radius: 10px; margin-bottom: .5rem; animation: riseIn .3s ease both; }
+
+/* ── SERVER-STATUS-SEITE ───────────────────────────── */
+.srv-card { margin-bottom: 1.3rem; }
+.srv-card.srv-down { border-left: 3px solid rgba(242, 63, 67, .6); }
+.srv-head { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; flex-wrap: wrap; }
+.srv-name { font-family: var(--display); font-size: 1.5rem; letter-spacing: .04em; }
+.srv-status { font-family: var(--mono); font-size: .78rem; letter-spacing: .08em; color: var(--muted); }
+.srv-status.on { color: var(--success); }
+.srv-status.off { color: #f23f43; }
+.srv-ping { color: var(--muted2); }
+.srv-bar { margin-top: .9rem; }
+.srv-players { font-family: var(--mono); font-size: .75rem; color: var(--muted); margin-top: .4rem; }
+.srv-map { color: var(--muted2); }
+.srv-chart { width: 100%; height: 90px; margin-top: 1rem; display: block; }
+.srv-chart-label {
+ font-family: var(--mono); font-size: .62rem; letter-spacing: .15em;
+ text-transform: uppercase; color: var(--muted2); margin-top: .25rem;
+}
+.srv-connect { display: flex; gap: .8rem; align-items: center; flex-wrap: wrap; margin-top: 1rem; }
+.srv-addr { font-size: .8rem; }
+.srv-checked { font-family: var(--mono); font-size: .62rem; color: var(--muted2); margin-top: .8rem; }
+
+/* ── AUDIT-LOG ─────────────────────────────────────── */
+.audit-list { max-height: 24rem; overflow-y: auto; }
+.audit-ts { font-family: var(--mono); font-size: .65rem; color: var(--muted2); white-space: nowrap; }
+.audit-action { font-family: var(--mono); font-size: .7rem; color: var(--neon); white-space: nowrap; }
+.audit-detail { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 18rem; }
diff --git a/src/bot/client.js b/src/bot/client.js
index 8e3f1ec..9ead111 100644
--- a/src/bot/client.js
+++ b/src/bot/client.js
@@ -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);
diff --git a/src/bot/giveaways.js b/src/bot/giveaways.js
index eaaf39f..ec30df0 100644
--- a/src/bot/giveaways.js
+++ b/src/bot/giveaways.js
@@ -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);
}
diff --git a/src/bot/server-monitor.js b/src/bot/server-monitor.js
index 5f3c363..7f5869b 100644
--- a/src/bot/server-monitor.js
+++ b/src/bot/server-monitor.js
@@ -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);
diff --git a/src/db.js b/src/db.js
index c5abbfb..ee70146 100644
--- a/src/db.js
+++ b/src/db.js
@@ -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 (
diff --git a/src/web/api.js b/src/web/api.js
index 03ca930..29365b7 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -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 };
});