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:
@@ -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) |
|
| 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) |
|
||||||
| 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal |
|
| 📋 **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 |
|
| 🎮 **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 |
|
| 💡 **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 |
|
| 📈 **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) |
|
| 👤 **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) |
|
| 🏓 `/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 |
|
| `/changelog` | öffentlich | Alle Releases mit Notes, Tag- und Pre-Release-Chips |
|
||||||
| `/feed.xml` | öffentlich | RSS-Feed der Devlogs |
|
| `/feed.xml` | öffentlich | RSS-Feed der Devlogs |
|
||||||
| `/profil` | Member | Eigenes Profil: Rang, XP, Rollen (togglebar), Alpha-Key |
|
| `/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) |
|
| `/commits` | nur Admin | Archivierte Commits aller Repos (SHA → Gitea-Link) |
|
||||||
| `/settings` | nur Admin | **Setup-Seite** — siehe unten |
|
| `/settings` | nur Admin | **Setup-Seite** — siehe unten |
|
||||||
|
|
||||||
|
|||||||
+75
-13
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Routes, Route, NavLink, Navigate } from 'react-router-dom';
|
import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom';
|
||||||
import { apiGet } from './api.js';
|
import { apiGet } from './api.js';
|
||||||
import Devlogs from './pages/Devlogs.jsx';
|
import Devlogs from './pages/Devlogs.jsx';
|
||||||
import Changelog from './pages/Changelog.jsx';
|
import Changelog from './pages/Changelog.jsx';
|
||||||
@@ -10,6 +10,33 @@ import Events from './pages/Events.jsx';
|
|||||||
import Commits from './pages/Commits.jsx';
|
import Commits from './pages/Commits.jsx';
|
||||||
import Settings from './pages/Settings.jsx';
|
import Settings from './pages/Settings.jsx';
|
||||||
import Profil from './pages/Profil.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 (
|
||||||
|
<div className={`nav-drop ${open ? 'open' : ''}`} ref={ref}>
|
||||||
|
<button className={`nav-drop-btn ${active ? 'active' : ''}`} onClick={() => setOpen((o) => !o)}>
|
||||||
|
{label} <span className="nav-caret">▾</span>
|
||||||
|
</button>
|
||||||
|
{open && <div className="nav-drop-menu" onClick={() => setOpen(false)}>{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DiscordMark() {
|
function DiscordMark() {
|
||||||
return (
|
return (
|
||||||
@@ -51,6 +78,12 @@ export default function App() {
|
|||||||
// Login-Status einmal beim Laden holen
|
// Login-Status einmal beim Laden holen
|
||||||
const [me, setMe] = useState({ user: null, admin: false, scopes: [], loading: true });
|
const [me, setMe] = useState({ user: null, admin: false, scopes: [], loading: true });
|
||||||
const [scrolled, setScrolled] = useState(false);
|
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)
|
// 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 [gated, setGated] = useState(() => new URLSearchParams(window.location.search).has('gate'));
|
||||||
const [invite, setInvite] = useState(null);
|
const [invite, setInvite] = useState(null);
|
||||||
@@ -83,30 +116,58 @@ export default function App() {
|
|||||||
<nav className="nav-links">
|
<nav className="nav-links">
|
||||||
<NavLink to="/devlogs">Devlog</NavLink>
|
<NavLink to="/devlogs">Devlog</NavLink>
|
||||||
<NavLink to="/roadmap">Roadmap</NavLink>
|
<NavLink to="/roadmap">Roadmap</NavLink>
|
||||||
<NavLink to="/galerie">Galerie</NavLink>
|
<NavLink to="/server">Server</NavLink>
|
||||||
<NavLink to="/level">Level</NavLink>
|
<NavDropdown label="Community" active={communityActive}>
|
||||||
<NavLink to="/events">Events</NavLink>
|
{COMMUNITY_LINKS.map(([to, label]) => (
|
||||||
<NavLink to="/changelog">Changelog</NavLink>
|
<NavLink to={to} key={to}>{label}</NavLink>
|
||||||
{me.user && <NavLink to="/profil">Profil</NavLink>}
|
))}
|
||||||
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
</NavDropdown>
|
||||||
{(me.admin || me.scopes?.length > 0) && <NavLink to="/settings">Setup</NavLink>}
|
{(me.admin || me.scopes?.length > 0) && <NavLink to="/settings">Setup</NavLink>}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="auth">
|
<div className="auth">
|
||||||
{me.loading ? null : me.user ? (
|
{me.loading ? null : me.user ? (
|
||||||
<div className="user-chip">
|
<NavDropdown
|
||||||
{me.user.avatar && <img className="avatar" src={me.user.avatar} alt="" />}
|
label={
|
||||||
<span className="username">{me.user.username}</span>
|
<span className="user-chip-inner">
|
||||||
<a className="logout" href="/auth/logout" title="Logout">✕</a>
|
{me.user.avatar && <img className="avatar" src={me.user.avatar} alt="" />}
|
||||||
</div>
|
<span className="username">{me.user.username}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<NavLink to="/profil">Profil</NavLink>
|
||||||
|
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
||||||
|
<a href="/auth/logout">Logout</a>
|
||||||
|
</NavDropdown>
|
||||||
) : (
|
) : (
|
||||||
<a className="btn-login" href="/auth/login">
|
<a className="btn-login" href="/auth/login">
|
||||||
<DiscordMark /> Login
|
<DiscordMark /> Login
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button className="burger" onClick={() => setDrawer((d) => !d)} aria-label="Menü">
|
||||||
|
{drawer ? '✕' : '☰'}
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{drawer && (
|
||||||
|
<nav className="drawer" onClick={() => setDrawer(false)}>
|
||||||
|
<NavLink to="/devlogs">Devlog</NavLink>
|
||||||
|
<NavLink to="/roadmap">Roadmap</NavLink>
|
||||||
|
<NavLink to="/server">Server</NavLink>
|
||||||
|
{COMMUNITY_LINKS.map(([to, label]) => (
|
||||||
|
<NavLink to={to} key={to}>{label}</NavLink>
|
||||||
|
))}
|
||||||
|
{me.user && <NavLink to="/profil">Profil</NavLink>}
|
||||||
|
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
||||||
|
{(me.admin || me.scopes?.length > 0) && <NavLink to="/settings">Setup</NavLink>}
|
||||||
|
{me.user
|
||||||
|
? <a href="/auth/logout">Logout</a>
|
||||||
|
: <a href="/auth/login">Login mit Discord</a>}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="main">
|
<main className="main">
|
||||||
{gated && (
|
{gated && (
|
||||||
<div className="gate-banner">
|
<div className="gate-banner">
|
||||||
@@ -126,6 +187,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<Navigate to="/devlogs" replace />} />
|
<Route path="/" element={<Navigate to="/devlogs" replace />} />
|
||||||
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
||||||
<Route path="/roadmap" element={<Roadmap />} />
|
<Route path="/roadmap" element={<Roadmap />} />
|
||||||
|
<Route path="/server" element={<Server />} />
|
||||||
<Route path="/galerie" element={<Galerie />} />
|
<Route path="/galerie" element={<Galerie />} />
|
||||||
<Route path="/level" element={<Level />} />
|
<Route path="/level" element={<Level />} />
|
||||||
<Route path="/events" element={<Events />} />
|
<Route path="/events" element={<Events />} />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="lightbox"
|
||||||
|
onClick={onClose}
|
||||||
|
onTouchStart={(e) => 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"
|
||||||
|
>
|
||||||
|
<button className="lb-close" onClick={onClose} title="Schließen (Esc)">✕</button>
|
||||||
|
{images.length > 1 && (
|
||||||
|
<button className="lb-nav lb-prev" onClick={(e) => { e.stopPropagation(); prev(); }} title="Zurück (←)">‹</button>
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
className="lb-img"
|
||||||
|
src={img.src ?? img}
|
||||||
|
alt={img.alt ?? ''}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
{images.length > 1 && (
|
||||||
|
<button className="lb-nav lb-next" onClick={(e) => { e.stopPropagation(); next(); }} title="Weiter (→)">›</button>
|
||||||
|
)}
|
||||||
|
<div className="lb-meta" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{img.caption && <span>{img.caption}</span>}
|
||||||
|
{images.length > 1 && <span className="lb-count">{index + 1} / {images.length}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Schimmernde Lade-Platzhalter statt „Lade …"-Text
|
||||||
|
|
||||||
|
/** n Karten-Platzhalter (Devlogs, Roadmap, Changelog, Server …) */
|
||||||
|
export function SkeletonCards({ n = 3 }) {
|
||||||
|
return (
|
||||||
|
<div aria-hidden="true">
|
||||||
|
{Array.from({ length: n }, (_, i) => (
|
||||||
|
<div className="card skel-card" key={i} style={{ animationDelay: `${i * 0.08}s` }}>
|
||||||
|
<div className="skel skel-line" style={{ width: '38%' }} />
|
||||||
|
<div className="skel skel-line" style={{ width: '92%' }} />
|
||||||
|
<div className="skel skel-line" style={{ width: '75%' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bild-Raster-Platzhalter (Galerie) */
|
||||||
|
export function SkeletonGrid({ n = 6 }) {
|
||||||
|
return (
|
||||||
|
<div className="gallery-grid" aria-hidden="true">
|
||||||
|
{Array.from({ length: n }, (_, i) => (
|
||||||
|
<div className="skel skel-shot" key={i} style={{ animationDelay: `${i * 0.06}s` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeilen-Platzhalter (Level-Liste u. Ä.) */
|
||||||
|
export function SkeletonRows({ n = 8 }) {
|
||||||
|
return (
|
||||||
|
<div aria-hidden="true">
|
||||||
|
{Array.from({ length: n }, (_, i) => (
|
||||||
|
<div className="skel skel-row" key={i} style={{ animationDelay: `${i * 0.05}s` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { apiGet } from '../api.js';
|
import { apiGet } from '../api.js';
|
||||||
|
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||||
import { Markdown } from '../markdown.jsx';
|
import { Markdown } from '../markdown.jsx';
|
||||||
|
|
||||||
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
||||||
@@ -33,7 +34,7 @@ export default function Changelog() {
|
|||||||
|
|
||||||
<section className="content">
|
<section className="content">
|
||||||
{error && <p className="notice">Changelog konnte nicht geladen werden.</p>}
|
{error && <p className="notice">Changelog konnte nicht geladen werden.</p>}
|
||||||
{!error && !data && <p className="notice">Lade …</p>}
|
{!error && !data && <SkeletonCards n={3} />}
|
||||||
{data?.total === 0 && <p className="notice">Noch keine Releases veröffentlicht.</p>}
|
{data?.total === 0 && <p className="notice">Noch keine Releases veröffentlicht.</p>}
|
||||||
|
|
||||||
{data?.total > 0 &&
|
{data?.total > 0 &&
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { apiGet, apiDelete } from '../api.js';
|
import { apiGet, apiDelete } from '../api.js';
|
||||||
import { Markdown } from '../markdown.jsx';
|
import { Markdown } from '../markdown.jsx';
|
||||||
|
import Lightbox from '../components/Lightbox.jsx';
|
||||||
|
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||||
|
|
||||||
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
||||||
weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric',
|
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 [error, setError] = useState(false);
|
||||||
const [qInput, setQInput] = useState('');
|
const [qInput, setQInput] = useState('');
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
|
const [lightbox, setLightbox] = useState(null); // { images, start }
|
||||||
|
|
||||||
// Sucheingabe entprellen (300 ms), bei neuer Suche zurück auf Seite 1
|
// Sucheingabe entprellen (300 ms), bei neuer Suche zurück auf Seite 1
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -110,7 +113,7 @@ export default function Devlogs({ me }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="notice">Devlogs konnten nicht geladen werden.</p>}
|
{error && <p className="notice">Devlogs konnten nicht geladen werden.</p>}
|
||||||
{!error && !data && <p className="notice">Lade …</p>}
|
{!error && !data && <SkeletonCards n={3} />}
|
||||||
{data?.total === 0 && (
|
{data?.total === 0 && (
|
||||||
<p className="notice">{q ? `Keine Treffer für „${q}".` : 'Noch keine Devlogs archiviert.'}</p>
|
<p className="notice">{q ? `Keine Treffer für „${q}".` : 'Noch keine Devlogs archiviert.'}</p>
|
||||||
)}
|
)}
|
||||||
@@ -147,8 +150,14 @@ export default function Devlogs({ me }) {
|
|||||||
</div>
|
</div>
|
||||||
{d.images?.length > 0 && (
|
{d.images?.length > 0 && (
|
||||||
<div className={`card-images n${Math.min(d.images.length, 4)}`}>
|
<div className={`card-images n${Math.min(d.images.length, 4)}`}>
|
||||||
{d.images.map((src) => (
|
{d.images.map((src, i) => (
|
||||||
<a key={src} href={src} target="_blank" rel="noreferrer">
|
<a
|
||||||
|
key={src} href={src}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLightbox({ images: d.images, start: i });
|
||||||
|
}}
|
||||||
|
>
|
||||||
<img src={src} alt="Devlog-Screenshot" loading="lazy" />
|
<img src={src} alt="Devlog-Screenshot" loading="lazy" />
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
@@ -166,6 +175,14 @@ export default function Devlogs({ me }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{lightbox && (
|
||||||
|
<Lightbox
|
||||||
|
images={lightbox.images}
|
||||||
|
start={lightbox.start}
|
||||||
|
onClose={() => setLightbox(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{pages > 1 && (
|
{pages > 1 && (
|
||||||
<div className="pager">
|
<div className="pager">
|
||||||
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { apiGet } from '../api.js';
|
import { apiGet } from '../api.js';
|
||||||
|
import Lightbox from '../components/Lightbox.jsx';
|
||||||
|
import { SkeletonGrid } from '../components/Skeleton.jsx';
|
||||||
|
|
||||||
const dateFmt = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
const dateFmt = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
|
||||||
@@ -7,6 +9,7 @@ export default function Galerie() {
|
|||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
const [lightbox, setLightbox] = useState(null); // Start-Index oder null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setError(false);
|
setError(false);
|
||||||
@@ -33,12 +36,15 @@ export default function Galerie() {
|
|||||||
|
|
||||||
<section className="content">
|
<section className="content">
|
||||||
{error && <p className="notice">Galerie konnte nicht geladen werden.</p>}
|
{error && <p className="notice">Galerie konnte nicht geladen werden.</p>}
|
||||||
{!error && !data && <p className="notice">Lade …</p>}
|
{!error && !data && <SkeletonGrid n={9} />}
|
||||||
{data?.total === 0 && <p className="notice">Noch keine Screenshots — poste welche im Discord!</p>}
|
{data?.total === 0 && <p className="notice">Noch keine Screenshots — poste welche im Discord!</p>}
|
||||||
|
|
||||||
<div className="gallery-grid">
|
<div className="gallery-grid">
|
||||||
{shots.map((s) => (
|
{shots.map((s, i) => (
|
||||||
<a className="shot" href={s.src} target="_blank" rel="noreferrer" key={s.key}>
|
<a
|
||||||
|
className="shot" href={s.src} key={s.key}
|
||||||
|
onClick={(e) => { e.preventDefault(); setLightbox(i); }}
|
||||||
|
>
|
||||||
<img src={s.src} alt={`Screenshot von ${s.author}`} loading="lazy" />
|
<img src={s.src} alt={`Screenshot von ${s.author}`} loading="lazy" />
|
||||||
<span className="shot-meta">
|
<span className="shot-meta">
|
||||||
<span className="shot-author">{s.author}</span>
|
<span className="shot-author">{s.author}</span>
|
||||||
@@ -48,6 +54,17 @@ export default function Galerie() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{lightbox !== null && (
|
||||||
|
<Lightbox
|
||||||
|
images={shots.map((s) => ({
|
||||||
|
src: s.src,
|
||||||
|
caption: `${s.author} · ${dateFmt.format(new Date(s.date))}`,
|
||||||
|
}))}
|
||||||
|
start={lightbox}
|
||||||
|
onClose={() => setLightbox(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{pages > 1 && (
|
{pages > 1 && (
|
||||||
<div className="pager">
|
<div className="pager">
|
||||||
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { apiGet } from '../api.js';
|
import { apiGet } from '../api.js';
|
||||||
|
import { SkeletonRows } from '../components/Skeleton.jsx';
|
||||||
|
|
||||||
/** Muss der Formel in src/bot/levels.js entsprechen */
|
/** Muss der Formel in src/bot/levels.js entsprechen */
|
||||||
function xpForLevel(level) {
|
function xpForLevel(level) {
|
||||||
@@ -64,7 +65,7 @@ export default function Level() {
|
|||||||
|
|
||||||
<section className="content">
|
<section className="content">
|
||||||
{error && <p className="notice">Bestenliste konnte nicht geladen werden.</p>}
|
{error && <p className="notice">Bestenliste konnte nicht geladen werden.</p>}
|
||||||
{!error && !rows && <p className="notice">Lade …</p>}
|
{!error && !rows && <SkeletonRows n={10} />}
|
||||||
{rows?.length === 0 && <p className="notice">Noch keine XP vergeben — schreibt was im Discord! ✍️</p>}
|
{rows?.length === 0 && <p className="notice">Noch keine XP vergeben — schreibt was im Discord! ✍️</p>}
|
||||||
|
|
||||||
{rows?.length > 0 && (
|
{rows?.length > 0 && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { apiGet, apiPost } from '../api.js';
|
import { apiGet, apiPost } from '../api.js';
|
||||||
import { Markdown } from '../markdown.jsx';
|
import { Markdown } from '../markdown.jsx';
|
||||||
|
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||||
|
|
||||||
const dueFmt = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' });
|
const dueFmt = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
@@ -136,7 +137,7 @@ export default function Roadmap() {
|
|||||||
|
|
||||||
<section className="content">
|
<section className="content">
|
||||||
{error && <p className="notice">Roadmap konnte nicht geladen werden.</p>}
|
{error && <p className="notice">Roadmap konnte nicht geladen werden.</p>}
|
||||||
{!error && !data && <p className="notice">Lade …</p>}
|
{!error && !data && <SkeletonCards n={3} />}
|
||||||
{data?.milestones.length === 0 && (
|
{data?.milestones.length === 0 && (
|
||||||
<p className="notice">Noch keine Meilensteine angelegt.</p>
|
<p className="notice">Noch keine Meilensteine angelegt.</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Öffentliche Server-Status-Seite: Live-Status + 24h-Spielerzahl-Verlauf
|
||||||
|
// (Daten sammelt der Server-Monitor alle 2 Minuten).
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { apiGet } from '../api.js';
|
||||||
|
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||||
|
|
||||||
|
const timeFmt = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
/** 24h-Verlauf als SVG-Fläche; Offline-Samples als rote Punkte auf der Nulllinie */
|
||||||
|
function HistoryChart({ history, max }) {
|
||||||
|
if (history.length < 2) return null;
|
||||||
|
const W = 600, H = 90, PAD = 4;
|
||||||
|
const peak = Math.max(max ?? 0, 1, ...history.map((h) => h.players ?? 0));
|
||||||
|
const x = (i) => PAD + (i / (history.length - 1)) * (W - PAD * 2);
|
||||||
|
const y = (p) => H - PAD - ((p ?? 0) / peak) * (H - PAD * 2);
|
||||||
|
|
||||||
|
const points = history.map((h, i) => `${x(i).toFixed(1)},${y(h.online ? h.players : 0).toFixed(1)}`);
|
||||||
|
const area = `${PAD},${H - PAD} ${points.join(' ')} ${W - PAD},${H - PAD}`;
|
||||||
|
const offline = history.map((h, i) => (h.online ? null : i)).filter((i) => i !== null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg className="srv-chart" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="srvFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="var(--neon)" stopOpacity=".35" />
|
||||||
|
<stop offset="100%" stopColor="var(--neon)" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<polygon points={area} fill="url(#srvFill)" />
|
||||||
|
<polyline points={points.join(' ')} fill="none" stroke="var(--neon)" strokeWidth="1.5" />
|
||||||
|
{offline.map((i) => (
|
||||||
|
<circle key={i} cx={x(i)} cy={H - PAD} r="2" fill="#f23f43" />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerCard({ s }) {
|
||||||
|
const pct = s.online && s.max ? Math.round((s.players / s.max) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<article className={`card srv-card ${s.online === false ? 'srv-down' : ''}`}>
|
||||||
|
<div className="srv-head">
|
||||||
|
<span className="srv-name">{s.icon} {s.name}</span>
|
||||||
|
<span className={`srv-status ${s.online ? 'on' : s.online === false ? 'off' : ''}`}>
|
||||||
|
{s.online === null ? '⏳ Warte auf Check' : s.online ? '🟢 Online' : '🔴 Offline'}
|
||||||
|
{s.ping != null && s.online && <span className="srv-ping"> · {s.ping}ms</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{s.online && s.players != null && s.max > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="xp-bar srv-bar">
|
||||||
|
<div className="xp-fill" style={{ width: `${Math.min(100, pct)}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="srv-players">
|
||||||
|
{s.players} / {s.max} Spieler ({pct}%)
|
||||||
|
{s.map && <span className="srv-map"> · 🗺️ {s.map}</span>}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{s.history.length > 1 && <HistoryChart history={s.history} max={s.max} />}
|
||||||
|
{s.history.length > 1 && <p className="srv-chart-label">Spielerzahl · letzte 24h</p>}
|
||||||
|
|
||||||
|
{(s.connect_url || s.address) && (
|
||||||
|
<div className="srv-connect">
|
||||||
|
{s.connect_url && (
|
||||||
|
<a className="btn btn-save" href={s.connect_url}>Connect</a>
|
||||||
|
)}
|
||||||
|
{s.address && <code className="alpha-key srv-addr">{s.address}</code>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{s.checkedAt && (
|
||||||
|
<p className="srv-checked">Zuletzt geprüft: {timeFmt.format(new Date(s.checkedAt))} Uhr</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Server() {
|
||||||
|
const [servers, setServers] = useState(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = () => apiGet('/api/servers').then((d) => setServers(d.servers)).catch(() => setError(true));
|
||||||
|
load();
|
||||||
|
const timer = setInterval(load, 60_000);
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-header">
|
||||||
|
<div className="page-bg-text">SERVER</div>
|
||||||
|
<div className="page-header-grid" aria-hidden="true" />
|
||||||
|
<div className="page-header-content">
|
||||||
|
<div className="page-tag">// Live-Status</div>
|
||||||
|
<h1 className="page-title">Server</h1>
|
||||||
|
<p className="page-subtitle">
|
||||||
|
Alle Game-Server auf einen Blick — aktualisiert alle 2 Minuten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="content">
|
||||||
|
{error && <p className="notice">Server-Status konnte nicht geladen werden.</p>}
|
||||||
|
{!error && !servers && <SkeletonCards n={2} />}
|
||||||
|
{servers?.length === 0 && <p className="notice">Noch keine Server eingetragen.</p>}
|
||||||
|
{servers?.map((s) => <ServerCard s={s} key={s.id} />)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -160,6 +160,7 @@ export default function Settings({ me }) {
|
|||||||
// Bot-Beschreibung + Team
|
// Bot-Beschreibung + Team
|
||||||
const [botDesc, setBotDesc] = useState('');
|
const [botDesc, setBotDesc] = useState('');
|
||||||
const [team, setTeam] = useState([]);
|
const [team, setTeam] = useState([]);
|
||||||
|
const [audit, setAudit] = useState([]);
|
||||||
const [teamDraft, setTeamDraft] = useState({ user_id: '', scopes: [] });
|
const [teamDraft, setTeamDraft] = useState({ user_id: '', scopes: [] });
|
||||||
|
|
||||||
const canTab = (id) => {
|
const canTab = (id) => {
|
||||||
@@ -201,6 +202,7 @@ export default function Settings({ me }) {
|
|||||||
apiGet('/api/triggers').then((d) => setTriggers(d.triggers)).catch(() => {});
|
apiGet('/api/triggers').then((d) => setTriggers(d.triggers)).catch(() => {});
|
||||||
apiGet('/api/gameservers').then((d) => setServers(d.servers)).catch(() => {});
|
apiGet('/api/gameservers').then((d) => setServers(d.servers)).catch(() => {});
|
||||||
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(() => {});
|
||||||
}, [me.admin, me.scopes?.length]);
|
}, [me.admin, me.scopes?.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1465,6 +1467,25 @@ export default function Settings({ me }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{audit.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2 className="settings-title" style={{ marginTop: '2rem' }}>// Audit-Log</h2>
|
||||||
|
<p className="field-hint" style={{ marginBottom: '.8rem' }}>
|
||||||
|
Die letzten Aktionen im Webinterface (max. 500 werden aufbewahrt).
|
||||||
|
</p>
|
||||||
|
<div className="key-list audit-list">
|
||||||
|
{audit.map((a) => (
|
||||||
|
<div className="key-row" key={a.id}>
|
||||||
|
<span className="audit-ts">{a.ts.slice(0, 16).replace('T', ' ')}</span>
|
||||||
|
<span className="key-name">{a.username}</span>
|
||||||
|
<span className="audit-action">{a.action}</span>
|
||||||
|
<span className="key-used audit-detail" title={a.detail}>{a.detail}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1255,3 +1255,151 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
|
|||||||
.profile-roles { justify-content: center; }
|
.profile-roles { justify-content: center; }
|
||||||
.wish-form { flex-direction: column; }
|
.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; }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { registerExtras } from './extras.js';
|
|||||||
import { handleApplyButton, handleApplyModal, handleReviewButton } from './app-forms.js';
|
import { handleApplyButton, handleApplyModal, handleReviewButton } from './app-forms.js';
|
||||||
import { closeTicketWithTranscript } from './tickets.js';
|
import { closeTicketWithTranscript } from './tickets.js';
|
||||||
import { handleRoleMenuButton } from './role-menus.js';
|
import { handleRoleMenuButton } from './role-menus.js';
|
||||||
|
import { handleGiveawayAdminButton } from './giveaways.js';
|
||||||
import {
|
import {
|
||||||
addPlaytester, removePlaytester, isWish, bumpWish,
|
addPlaytester, removePlaytester, isWish, bumpWish,
|
||||||
getGiveaway, toggleGiveawayEntry, giveawayEntries,
|
getGiveaway, toggleGiveawayEntry, giveawayEntries,
|
||||||
@@ -251,6 +252,16 @@ export async function startBot() {
|
|||||||
return;
|
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
|
// 🎉-Button: Giveaway-Teilnahme togglen
|
||||||
if (interaction.isButton() && interaction.customId === 'giveaway_enter') {
|
if (interaction.isButton() && interaction.customId === 'giveaway_enter') {
|
||||||
const g = getGiveaway(interaction.message.id);
|
const g = getGiveaway(interaction.message.id);
|
||||||
|
|||||||
+51
-3
@@ -1,6 +1,7 @@
|
|||||||
// Giveaway-Ziehung: Minuten-Scheduler beendet fällige Verlosungen automatisch
|
// Giveaway-Ziehung: Minuten-Scheduler beendet fällige Verlosungen automatisch,
|
||||||
import { EmbedBuilder } from 'discord.js';
|
// danach gibt's am Gewinner-Post einen Reroll- und Teilnehmer-Button (Admin).
|
||||||
import { dueGiveaways, markGiveawayEnded, giveawayEntries } from '../db.js';
|
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageFlags, PermissionFlagsBits } from 'discord.js';
|
||||||
|
import { dueGiveaways, markGiveawayEnded, giveawayEntries, getGiveaway } from '../db.js';
|
||||||
import { brandFooter } from '../runtime-settings.js';
|
import { brandFooter } from '../runtime-settings.js';
|
||||||
|
|
||||||
const CHECK_INTERVAL_MS = 60 * 1000;
|
const CHECK_INTERVAL_MS = 60 * 1000;
|
||||||
@@ -50,6 +51,7 @@ export async function giveawayTick(client) {
|
|||||||
await channel.send({
|
await channel.send({
|
||||||
content: `🎉 **${g.prize}** geht an: ${mentions} — Glückwunsch! 🏆`,
|
content: `🎉 **${g.prize}** geht an: ${mentions} — Glückwunsch! 🏆`,
|
||||||
allowedMentions: { users: winners },
|
allowedMentions: { users: winners },
|
||||||
|
components: [giveawayAdminRow(g.message_id)],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log(`[giveaway] '${g.prize}' beendet (${entries.length} Teilnahmen)`);
|
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) {
|
export function startGiveaways(client) {
|
||||||
setInterval(() => giveawayTick(client).catch((e) => console.error('[giveaway]', e)), CHECK_INTERVAL_MS);
|
setInterval(() => giveawayTick(client).catch((e) => console.error('[giveaway]', e)), CHECK_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// Down/Up-Alerts mit 2-Fails-Schwelle. Verwaltet im Setup-Tab "Server".
|
// Down/Up-Alerts mit 2-Fails-Schwelle. Verwaltet im Setup-Tab "Server".
|
||||||
import { ActivityType, EmbedBuilder, Events } from 'discord.js';
|
import { ActivityType, EmbedBuilder, Events } from 'discord.js';
|
||||||
import { GameDig } from 'gamedig';
|
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 { brandFooter, botStatusText } from '../runtime-settings.js';
|
||||||
import { brandEmbed } from '../embeds.js';
|
import { brandEmbed } from '../embeds.js';
|
||||||
|
|
||||||
@@ -25,6 +25,9 @@ export const GAME_ICONS = {
|
|||||||
// Alert-Zustand pro Server (in-memory)
|
// Alert-Zustand pro Server (in-memory)
|
||||||
const alertState = new Map(); // id → { fails, down, since }
|
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 */
|
/** Einen Server abfragen — fivem/http direkt, alles andere über gamedig */
|
||||||
export async function queryServer(server) {
|
export async function queryServer(server) {
|
||||||
const base = {
|
const base = {
|
||||||
@@ -155,6 +158,13 @@ export async function monitorTick(client) {
|
|||||||
|
|
||||||
const results = await Promise.all(servers.map(queryServer));
|
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
|
// Bot-Presence: Gesamtspielerzahl — nur wenn kein eigener Status (Brand-Tab) gesetzt ist
|
||||||
const withPlayers = results.filter((r) => r.online && r.players != null);
|
const withPlayers = results.filter((r) => r.online && r.players != null);
|
||||||
const totalPlayers = withPlayers.reduce((sum, r) => sum + r.players, 0);
|
const totalPlayers = withPlayers.reduce((sum, r) => sum + r.players, 0);
|
||||||
|
|||||||
@@ -474,6 +474,30 @@ export const webAdminScopes = (userId) =>
|
|||||||
export const listWebAdmins = () => listWebAdminsStmt.all();
|
export const listWebAdmins = () => listWebAdminsStmt.all();
|
||||||
export const deleteWebAdmin = (userId) => deleteWebAdminStmt.run(userId).changes > 0;
|
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
|
// Game-Server (DiscordGSM-Stil): strukturierte Server statt CSV-Setting
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS gameservers (
|
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 getGameserver = (id) => getGameserverStmt.get(id) ?? null;
|
||||||
export const deleteGameserver = (id) => deleteGameserverStmt.run(id).changes > 0;
|
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
|
// Moderation: Verwarnungen + Sticky-Roles
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS warns (
|
CREATE TABLE IF NOT EXISTS warns (
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import {
|
|||||||
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
|
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
|
||||||
saveTemplate, listTemplates, deleteTemplate,
|
saveTemplate, listTemplates, deleteTemplate,
|
||||||
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
||||||
|
logAudit, listAudit, playerHistory,
|
||||||
} from '../db.js';
|
} from '../db.js';
|
||||||
|
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
|
||||||
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
||||||
import { sanitizeEmbed } from './api-v1.js';
|
import { sanitizeEmbed } from './api-v1.js';
|
||||||
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.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() }));
|
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
|
// 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 () => ({
|
||||||
@@ -515,6 +540,7 @@ ${rssItems}
|
|||||||
}
|
}
|
||||||
|
|
||||||
request.log.info('Settings per Web-UI aktualisiert');
|
request.log.info('Settings per Web-UI aktualisiert');
|
||||||
|
logAudit(getSessionUser(request), 'settings', Object.keys(body).join(', ').slice(0, 300));
|
||||||
return { ok: true, settings: currentSettings() };
|
return { ok: true, settings: currentSettings() };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -581,6 +607,7 @@ ${rssItems}
|
|||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
const id = createRoleMenu(menu);
|
const id = createRoleMenu(menu);
|
||||||
request.log.info(`Rollen-Menü ${id} erstellt`);
|
request.log.info(`Rollen-Menü ${id} erstellt`);
|
||||||
|
logAudit(getSessionUser(request), 'rollen-menü erstellt', menu.title);
|
||||||
return { ok: true, menu: menuToJson(getRoleMenu(id)) };
|
return { ok: true, menu: menuToJson(getRoleMenu(id)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -601,6 +628,7 @@ ${rssItems}
|
|||||||
request.log.warn(`Menü ${id}: Discord-Update fehlgeschlagen: ${error.message}`);
|
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)) };
|
return { ok: true, published, menu: menuToJson(getRoleMenu(id)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -622,6 +650,7 @@ ${rssItems}
|
|||||||
await unpublishRoleMenu(client, menu);
|
await unpublishRoleMenu(client, menu);
|
||||||
deleteRoleMenu(menu.id);
|
deleteRoleMenu(menu.id);
|
||||||
request.log.info(`Rollen-Menü ${menu.id} gelöscht`);
|
request.log.info(`Rollen-Menü ${menu.id} gelöscht`);
|
||||||
|
logAudit(getSessionUser(request), 'rollen-menü gelöscht', menu.title);
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -802,6 +831,7 @@ ${rssItems}
|
|||||||
const server = parseServerBody(request, reply);
|
const server = parseServerBody(request, reply);
|
||||||
if (!server) return;
|
if (!server) return;
|
||||||
const id = createGameserver(server);
|
const id = createGameserver(server);
|
||||||
|
logAudit(getSessionUser(request), 'server hinzugefügt', server.name);
|
||||||
return { ok: true, server: getGameserver(id) };
|
return { ok: true, server: getGameserver(id) };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -812,6 +842,7 @@ ${rssItems}
|
|||||||
const server = parseServerBody(request, reply);
|
const server = parseServerBody(request, reply);
|
||||||
if (!server) return;
|
if (!server) return;
|
||||||
updateGameserver({ ...server, id });
|
updateGameserver({ ...server, id });
|
||||||
|
logAudit(getSessionUser(request), 'server geändert', server.name);
|
||||||
return { ok: true, server: getGameserver(id) };
|
return { ok: true, server: getGameserver(id) };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -827,6 +858,7 @@ ${rssItems}
|
|||||||
await message?.delete().catch(() => {});
|
await message?.delete().catch(() => {});
|
||||||
}
|
}
|
||||||
deleteGameserver(server.id);
|
deleteGameserver(server.id);
|
||||||
|
logAudit(getSessionUser(request), 'server gelöscht', server.name);
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -964,11 +996,13 @@ ${rssItems}
|
|||||||
}
|
}
|
||||||
await message.edit(payload);
|
await message.edit(payload);
|
||||||
request.log.info(`Composer: Nachricht ${messageId} bearbeitet`);
|
request.log.info(`Composer: Nachricht ${messageId} bearbeitet`);
|
||||||
|
logAudit(getSessionUser(request), 'composer bearbeitet', `#${channel.name ?? channelId}`);
|
||||||
return { ok: true, edited: true, message_id: String(messageId) };
|
return { ok: true, edited: true, message_id: String(messageId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = await channel.send(payload);
|
const message = await channel.send(payload);
|
||||||
request.log.info(`Composer: Nachricht ${message.id} gesendet`);
|
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 };
|
return { ok: true, edited: false, message_id: message.id };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -992,6 +1026,7 @@ ${rssItems}
|
|||||||
if (target === 'avatar') await client.user.setAvatar(buffer);
|
if (target === 'avatar') await client.user.setAvatar(buffer);
|
||||||
else await client.user.setBanner(buffer);
|
else await client.user.setBanner(buffer);
|
||||||
request.log.info(`Bot-${target} aktualisiert (${(buffer.length / 1024).toFixed(0)} KB)`);
|
request.log.info(`Bot-${target} aktualisiert (${(buffer.length / 1024).toFixed(0)} KB)`);
|
||||||
|
logAudit(getSessionUser(request), 'branding', `bot-${target} geändert`);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
request.log.error({ err: error }, `Bot-${target} fehlgeschlagen`);
|
request.log.error({ err: error }, `Bot-${target} fehlgeschlagen`);
|
||||||
@@ -1019,6 +1054,7 @@ ${rssItems}
|
|||||||
return reply.code(400).send({ error: 'Vorlage ist leer' });
|
return reply.code(400).send({ error: 'Vorlage ist leer' });
|
||||||
}
|
}
|
||||||
saveTemplate(name, payload);
|
saveTemplate(name, payload);
|
||||||
|
logAudit(getSessionUser(request), 'vorlage gespeichert', name);
|
||||||
return { ok: true, templates: listTemplates() };
|
return { ok: true, templates: listTemplates() };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1210,14 +1246,22 @@ ${rssItems}
|
|||||||
}
|
}
|
||||||
saveWebAdmin(userId, username, scopes);
|
saveWebAdmin(userId, username, scopes);
|
||||||
request.log.info(`Team-Mitglied ${username} (${userId}): ${scopes.join(',')}`);
|
request.log.info(`Team-Mitglied ${username} (${userId}): ${scopes.join(',')}`);
|
||||||
|
logAudit(getSessionUser(request), 'team geändert', `${username}: ${scopes.join(',')}`);
|
||||||
return { ok: true, admins: listWebAdmins() };
|
return { ok: true, admins: listWebAdmins() };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/webadmins/:id', async (request, reply) => {
|
app.delete('/api/webadmins/:id', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireAdmin(request, reply)) return;
|
||||||
|
logAudit(getSessionUser(request), 'team entfernt', String(request.params.id));
|
||||||
return { deleted: deleteWebAdmin(String(request.params.id)), admins: listWebAdmins() };
|
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/* ---
|
// --- API-Keys (Admin) — für /api/v1/* ---
|
||||||
|
|
||||||
const VALID_SCOPES = ['message', 'dm', 'roles', 'read'];
|
const VALID_SCOPES = ['message', 'dm', 'roles', 'read'];
|
||||||
@@ -1237,6 +1281,7 @@ ${rssItems}
|
|||||||
}
|
}
|
||||||
const { id, key } = createApiKey(name, scopes);
|
const { id, key } = createApiKey(name, scopes);
|
||||||
request.log.info(`API-Key '${name}' erstellt (Scopes: ${scopes.join(',')})`);
|
request.log.info(`API-Key '${name}' erstellt (Scopes: ${scopes.join(',')})`);
|
||||||
|
logAudit(getSessionUser(request), 'api-key erstellt', name);
|
||||||
// Klartext-Key nur in dieser einen Antwort!
|
// Klartext-Key nur in dieser einen Antwort!
|
||||||
return { id, key, name, scopes };
|
return { id, key, name, scopes };
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user