Startseite, Server-Seite v2 mit Logos, Lightbox-Fix, Navbar-Ausrichtung

- Startseite (/): Hero mit CTAs (Discord-Invite + Devlogs), Live-Zahlen
  (Member, Server online, Spieler), Bereichs-Kacheln, neuestes Devlog
  als Teaser — kein Redirect mehr auf /devlogs
- Server-Seite v2: Übersichtsleiste (X/Y online, Spieler gesamt),
  2-Spalten-Grid, Server-Logo (neues Feld image_url, https-only, auch
  als Embed-Thumbnail im Discord-Status), große Spielerzahl, Uptime %
  + Peak aus den 24h-Samples, Adresse als Copy-Button
- Lightbox: rendert jetzt per Portal in <body> (Header/Scanlines
  schienen durchs Overlay), Pfeile stehen neben dem Bild statt drauf,
  Info-Zeile unterm Bild
- Navbar: Community-Dropdown-Button auf Link-Höhe ausgerichtet
  (line-height + align-items)
- Setup Server-Tab: Logo-URL-Feld

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 22:25:30 +02:00
co-authored by Claude Fable 5
parent be11398f7c
commit ef0fb066f7
10 changed files with 316 additions and 68 deletions
+2 -1
View File
@@ -63,7 +63,8 @@ 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 | | `/` | öffentlich | Startseite: Hero, Live-Zahlen (Member/Server/Spieler), Bereichs-Kacheln, neuestes Devlog |
| `/server` | öffentlich | Live-Status aller Game-Server: Logo, Spieler-Balken, 24h-Verlauf, Uptime & Peak, Copy-Adresse |
| `/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 |
+2 -1
View File
@@ -11,6 +11,7 @@ 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'; import Server from './pages/Server.jsx';
import Home from './pages/Home.jsx';
const COMMUNITY_LINKS = [ const COMMUNITY_LINKS = [
['/galerie', 'Galerie'], ['/galerie', 'Galerie'],
@@ -184,7 +185,7 @@ export default function App() {
</div> </div>
)} )}
<Routes> <Routes>
<Route path="/" element={<Navigate to="/devlogs" replace />} /> <Route path="/" element={<Home />} />
<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="/server" element={<Server />} />
+16 -13
View File
@@ -1,5 +1,8 @@
// Bild-Overlay mit Blättern (Pfeiltasten, Buttons, Wischen) statt neuem Tab. // Bild-Overlay mit Blättern (Pfeiltasten, Buttons, Wischen) statt neuem Tab.
// Rendert per Portal direkt in <body>, damit Header/Scanlines nicht durchscheinen;
// die Pfeile stehen NEBEN dem Bild, die Info-Zeile darunter.
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
export default function Lightbox({ images, start = 0, onClose }) { export default function Lightbox({ images, start = 0, onClose }) {
const [index, setIndex] = useState(start); const [index, setIndex] = useState(start);
@@ -25,7 +28,7 @@ export default function Lightbox({ images, start = 0, onClose }) {
const img = images[index]; const img = images[index];
if (!img) return null; if (!img) return null;
return ( return createPortal(
<div <div
className="lightbox" className="lightbox"
onClick={onClose} onClick={onClose}
@@ -40,22 +43,22 @@ export default function Lightbox({ images, start = 0, onClose }) {
role="dialog" aria-modal="true" role="dialog" aria-modal="true"
> >
<button className="lb-close" onClick={onClose} title="Schließen (Esc)"></button> <button className="lb-close" onClick={onClose} title="Schließen (Esc)"></button>
<div className="lb-row">
{images.length > 1 && ( {images.length > 1 && (
<button className="lb-nav lb-prev" onClick={(e) => { e.stopPropagation(); prev(); }} title="Zurück (←)"></button> <button className="lb-nav" onClick={(e) => { e.stopPropagation(); prev(); }} title="Zurück (←)"></button>
)} )}
<img <figure className="lb-figure" onClick={(e) => e.stopPropagation()}>
className="lb-img" <img className="lb-img" src={img.src ?? img} alt={img.alt ?? ''} />
src={img.src ?? img} <figcaption className="lb-meta">
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>} {img.caption && <span>{img.caption}</span>}
{images.length > 1 && <span className="lb-count">{index + 1} / {images.length}</span>} {images.length > 1 && <span className="lb-count">{index + 1} / {images.length}</span>}
</figcaption>
</figure>
{images.length > 1 && (
<button className="lb-nav" onClick={(e) => { e.stopPropagation(); next(); }} title="Weiter (→)"></button>
)}
</div> </div>
</div> </div>,
document.body
); );
} }
+112
View File
@@ -0,0 +1,112 @@
// Startseite: Hero mit CTAs, Live-Zahlen, neuestes Devlog und Bereichs-Kacheln.
// Nutzt nur öffentliche APIs — alles best effort, die Seite steht auch ohne Daten.
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { apiGet } from '../api.js';
import { Markdown } from '../markdown.jsx';
const TILES = [
['/devlogs', '📔', 'Devlog', 'Entwicklungs-Updates, frisch aus dem Editor'],
['/roadmap', '🗺️', 'Roadmap', 'Meilensteine + Community-Wünsche zum Abstimmen'],
['/server', '🎮', 'Server', 'Live-Status aller Game-Server mit Verlauf'],
['/galerie', '🖼️', 'Galerie', 'Screenshots aus der Community'],
['/level', '📈', 'Level', 'XP-Bestenliste — wer redet am meisten mit?'],
['/events', '📅', 'Events', 'Playtests, Streams und was sonst ansteht'],
];
/** Devlog-Text für den Teaser kürzen (erste Sätze, ohne Titel-Zeile) */
function teaser(content) {
const body = content
.split(/\n{2,}/)
.filter((b) => !/^Devlog\s*[—–-]/i.test(b.trim()) && !/^\*[^*]+\*$/s.test(b.trim()))
.join('\n\n');
return body.length > 420 ? `${body.slice(0, 420).trimEnd()}` : body;
}
export default function Home() {
const [stats, setStats] = useState(null);
const [servers, setServers] = useState(null);
const [latest, setLatest] = useState(null);
const [invite, setInvite] = useState(null);
useEffect(() => {
apiGet('/api/serverstats').then(setStats).catch(() => {});
apiGet('/api/servers').then((d) => setServers(d.servers)).catch(() => {});
apiGet('/api/devlogs?page=1').then((d) => setLatest(d.items?.[0] ?? null)).catch(() => {});
apiGet('/api/invite').then((d) => setInvite(d.invite)).catch(() => {});
window.scrollTo(0, 0);
}, []);
const online = servers?.filter((s) => s.online) ?? [];
const players = online.reduce((sum, s) => sum + (s.players ?? 0), 0);
return (
<>
<section className="page-header home-hero">
<div className="page-bg-text">D4RKST3R</div>
<div className="page-header-grid" aria-hidden="true" />
<div className="page-header-content">
<div className="page-tag">// Community-Hub</div>
<h1 className="page-title">
Willkommen<br />bei D4RKST<span className="home-accent">3</span>R
</h1>
<p className="page-subtitle">
Devlogs, Roadmap, Server-Status und alles aus der Community
an einem Ort, direkt aus dem Discord.
</p>
<div className="home-cta">
{invite && <a className="btn btn-save" href={invite} target="_blank" rel="noreferrer">💬 Zum Discord</a>}
<Link className="btn btn-ghost home-cta-alt" to="/devlogs">📔 Devlogs lesen</Link>
</div>
</div>
</section>
<section className="content">
{(stats || servers) && (
<div className="srv-summary home-stats">
{stats?.members > 0 && (
<div className="srv-sum-item">
<span className="srv-sum-n">{stats.members.toLocaleString('de-DE')}</span>
<span className="srv-sum-label">Member im Discord</span>
</div>
)}
{servers?.length > 0 && (
<div className="srv-sum-item">
<span className="srv-sum-n">{online.length}<span className="srv-sum-of"> / {servers.length}</span></span>
<span className="srv-sum-label">Server online</span>
</div>
)}
{online.length > 0 && (
<div className="srv-sum-item">
<span className="srv-sum-n">{players}</span>
<span className="srv-sum-label">Spieler gerade drauf</span>
</div>
)}
</div>
)}
<div className="home-tiles">
{TILES.map(([to, icon, title, desc]) => (
<Link className="card home-tile" to={to} key={to}>
<span className="home-tile-icon">{icon}</span>
<span className="home-tile-title">{title}</span>
<span className="home-tile-desc">{desc}</span>
</Link>
))}
</div>
{latest && (
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
<h2 className="settings-title">// Neuestes Devlog</h2>
<Link to="/devlogs" className="card home-devlog">
<div className="card-body">
<Markdown text={teaser(latest.content)} />
</div>
<span className="home-devlog-more">Weiterlesen </span>
</Link>
</div>
)}
</section>
</>
);
}
+72 -13
View File
@@ -1,5 +1,6 @@
// Öffentliche Server-Status-Seite: Live-Status + 24h-Spielerzahl-Verlauf // Öffentliche Server-Status-Seite: Übersichtsleiste, Karte pro Server mit Logo,
// (Daten sammelt der Server-Monitor alle 2 Minuten). // Live-Status, 24h-Spielerzahl-Verlauf, Uptime und Peak.
// Daten sammelt der Server-Monitor alle 2 Minuten.
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 { SkeletonCards } from '../components/Skeleton.jsx';
@@ -35,39 +36,78 @@ function HistoryChart({ history, max }) {
); );
} }
/** Uptime (%) und Spieler-Peak aus den 24h-Samples */
function historyStats(history) {
if (history.length === 0) return null;
const online = history.filter((h) => h.online).length;
return {
uptime: Math.round((online / history.length) * 100),
peak: Math.max(0, ...history.map((h) => h.players ?? 0)),
};
}
function ServerCard({ s }) { function ServerCard({ s }) {
const [copied, setCopied] = useState(false);
const pct = s.online && s.max ? Math.round((s.players / s.max) * 100) : 0; const pct = s.online && s.max ? Math.round((s.players / s.max) * 100) : 0;
const stats = historyStats(s.history);
async function copyAddress() {
try {
await navigator.clipboard.writeText(s.address);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch { /* Browser ohne Clipboard-Rechte */ }
}
return ( return (
<article className={`card srv-card ${s.online === false ? 'srv-down' : ''}`}> <article className={`card srv-card ${s.online === false ? 'srv-down' : ''}`}>
<div className="srv-head"> <div className="srv-head">
<span className="srv-name">{s.icon} {s.name}</span> {s.image_url
? <img className="srv-logo" src={s.image_url} alt="" loading="lazy" />
: <span className="srv-logo srv-logo-emoji">{s.icon}</span>}
<div className="srv-title">
<span className="srv-name">{s.name}</span>
<span className={`srv-status ${s.online ? 'on' : s.online === false ? 'off' : ''}`}> <span className={`srv-status ${s.online ? 'on' : s.online === false ? 'off' : ''}`}>
{s.online === null ? '⏳ Warte auf Check' : s.online ? '🟢 Online' : '🔴 Offline'} {s.online === null ? '⏳ Warte auf Check' : s.online ? '🟢 Online' : '🔴 Offline'}
{s.ping != null && s.online && <span className="srv-ping"> · {s.ping}ms</span>} {s.ping != null && s.online && <span className="srv-ping"> · {s.ping}ms</span>}
{s.map && s.online && <span className="srv-map"> · 🗺 {s.map}</span>}
</span> </span>
</div> </div>
{s.online && s.players != null && (
<div className="srv-count">
<span className="srv-count-n">{s.players}</span>
{s.max > 0 && <span className="srv-count-max">/ {s.max}</span>}
</div>
)}
</div>
{s.online && s.players != null && s.max > 0 && ( {s.online && s.players != null && s.max > 0 && (
<>
<div className="xp-bar srv-bar"> <div className="xp-bar srv-bar">
<div className="xp-fill" style={{ width: `${Math.min(100, pct)}%` }} /> <div className="xp-fill" style={{ width: `${Math.min(100, pct)}%` }} />
</div> </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 && <HistoryChart history={s.history} max={s.max} />}
{s.history.length > 1 && <p className="srv-chart-label">Spielerzahl · letzte 24h</p>} {stats && (
<div className="srv-stats">
<span>Spielerzahl · letzte 24h</span>
<span>
Peak <b>{stats.peak}</b> · Uptime{' '}
<b className={stats.uptime >= 99 ? 'up-good' : stats.uptime >= 90 ? '' : 'up-bad'}>
{stats.uptime}%
</b>
</span>
</div>
)}
{(s.connect_url || s.address) && ( {(s.connect_url || s.address) && (
<div className="srv-connect"> <div className="srv-connect">
{s.connect_url && ( {s.connect_url && <a className="btn btn-save" href={s.connect_url}>🎮 Connect</a>}
<a className="btn btn-save" href={s.connect_url}>Connect</a> {s.address && (
<button className="alpha-key srv-addr" onClick={copyAddress} title="Adresse kopieren">
{copied ? '✓ Kopiert!' : s.address}
</button>
)} )}
{s.address && <code className="alpha-key srv-addr">{s.address}</code>}
</div> </div>
)} )}
{s.checkedAt && ( {s.checkedAt && (
@@ -89,6 +129,9 @@ export default function Server() {
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
const online = servers?.filter((s) => s.online) ?? [];
const totalPlayers = online.reduce((sum, s) => sum + (s.players ?? 0), 0);
return ( return (
<> <>
<section className="page-header"> <section className="page-header">
@@ -107,7 +150,23 @@ export default function Server() {
{error && <p className="notice">Server-Status konnte nicht geladen werden.</p>} {error && <p className="notice">Server-Status konnte nicht geladen werden.</p>}
{!error && !servers && <SkeletonCards n={2} />} {!error && !servers && <SkeletonCards n={2} />}
{servers?.length === 0 && <p className="notice">Noch keine Server eingetragen.</p>} {servers?.length === 0 && <p className="notice">Noch keine Server eingetragen.</p>}
{servers?.length > 0 && (
<div className="srv-summary">
<div className="srv-sum-item">
<span className="srv-sum-n">{online.length}<span className="srv-sum-of"> / {servers.length}</span></span>
<span className="srv-sum-label">Server online</span>
</div>
<div className="srv-sum-item">
<span className="srv-sum-n">{totalPlayers}</span>
<span className="srv-sum-label">Spieler gerade drauf</span>
</div>
</div>
)}
<div className="srv-grid">
{servers?.map((s) => <ServerCard s={s} key={s.id} />)} {servers?.map((s) => <ServerCard s={s} key={s.id} />)}
</div>
</section> </section>
</> </>
); );
+4 -1
View File
@@ -154,7 +154,7 @@ export default function Settings({ me }) {
['terraria', '🌳 Terraria'], ['arma3', '🪖 Arma 3'], ['squad', '🪖 Squad'], ['terraria', '🌳 Terraria'], ['arma3', '🪖 Arma 3'], ['squad', '🪖 Squad'],
['unturned', '🏝️ Unturned'], ['unturned', '🏝️ Unturned'],
]; ];
const emptyServer = { id: null, name: '', type: 'fivem', query_url: '', address: '', host: '', port: '', connect_url: '' }; const emptyServer = { id: null, name: '', type: 'fivem', query_url: '', address: '', host: '', port: '', connect_url: '', image_url: '' };
const [servers, setServers] = useState([]); const [servers, setServers] = useState([]);
const [serverDraft, setServerDraft] = useState(emptyServer); const [serverDraft, setServerDraft] = useState(emptyServer);
// Bot-Beschreibung + Team // Bot-Beschreibung + Team
@@ -1372,6 +1372,7 @@ export default function Settings({ me }) {
<button className="btn btn-mini" onClick={() => setServerDraft({ <button className="btn btn-mini" onClick={() => setServerDraft({
id: s.id, name: s.name, type: s.type, query_url: s.query_url ?? '', id: s.id, name: s.name, type: s.type, query_url: s.query_url ?? '',
address: s.address ?? '', host: s.host ?? '', port: s.port ?? '', connect_url: s.connect_url ?? '', address: s.address ?? '', host: s.host ?? '', port: s.port ?? '', connect_url: s.connect_url ?? '',
image_url: s.image_url ?? '',
})}>Bearbeiten</button> })}>Bearbeiten</button>
<button className="card-delete" title="Server + Embed entfernen" onClick={() => removeServer(s.id)}></button> <button className="card-delete" title="Server + Embed entfernen" onClick={() => removeServer(s.id)}></button>
</div> </div>
@@ -1407,6 +1408,8 @@ export default function Settings({ me }) {
<input type="text" placeholder="fivem://connect/play.d4rkst3r.de" value={serverDraft.connect_url} onChange={(e) => setServerDraft({ ...serverDraft, connect_url: e.target.value })} /> <input type="text" placeholder="fivem://connect/play.d4rkst3r.de" value={serverDraft.connect_url} onChange={(e) => setServerDraft({ ...serverDraft, connect_url: e.target.value })} />
<input type="text" placeholder="connect play.d4rkst3r.de" value={serverDraft.address} onChange={(e) => setServerDraft({ ...serverDraft, address: e.target.value })} /> <input type="text" placeholder="connect play.d4rkst3r.de" value={serverDraft.address} onChange={(e) => setServerDraft({ ...serverDraft, address: e.target.value })} />
</div> </div>
<label style={{ marginTop: '.6rem' }}>Logo-URL <span className="field-hint">https-Bild (z. B. vom CDN) erscheint auf der /server-Seite und als Embed-Thumbnail</span></label>
<input type="text" placeholder="https://cdn.d4rkst3r.de/logos/eco.png" value={serverDraft.image_url} onChange={(e) => setServerDraft({ ...serverDraft, image_url: e.target.value })} />
<div className="field-row" style={{ marginTop: '.6rem' }}> <div className="field-row" style={{ marginTop: '.6rem' }}>
<button className="btn btn-save" onClick={saveServer}>{serverDraft.id ? 'Aktualisieren' : 'Hinzufügen'}</button> <button className="btn btn-save" onClick={saveServer}>{serverDraft.id ? 'Aktualisieren' : 'Hinzufügen'}</button>
{serverDraft.id && <button className="btn btn-ghost" onClick={() => setServerDraft(emptyServer)}>Abbrechen</button>} {serverDraft.id && <button className="btn btn-ghost" onClick={() => setServerDraft(emptyServer)}>Abbrechen</button>}
+87 -24
View File
@@ -118,7 +118,7 @@ body::after {
.nav-logo:hover { text-shadow: 0 0 18px rgba(245, 197, 24, .55); } .nav-logo:hover { text-shadow: 0 0 18px rgba(245, 197, 24, .55); }
.nav-logo span { color: var(--neon2); } .nav-logo span { color: var(--neon2); }
.nav-links { display: flex; gap: 1.6rem; flex: 1; } .nav-links { display: flex; gap: 1.6rem; flex: 1; align-items: center; }
.nav-links a { .nav-links a {
position: relative; position: relative;
font-family: var(--mono); font-family: var(--mono);
@@ -1257,13 +1257,14 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
} }
/* ── NAV-DROPDOWNS + BURGER ────────────────────────── */ /* ── NAV-DROPDOWNS + BURGER ────────────────────────── */
.nav-drop { position: relative; } .nav-drop { position: relative; display: flex; align-items: center; }
.nav-drop-btn { .nav-drop-btn {
display: inline-flex; align-items: center; gap: .35rem; display: inline-flex; align-items: center; gap: .35rem;
background: none; border: 0; cursor: pointer; background: none; border: 0; cursor: pointer;
font-family: var(--mono); font-size: .7rem; font-family: var(--mono); font-size: .7rem;
letter-spacing: .25em; text-transform: uppercase; letter-spacing: .25em; text-transform: uppercase;
color: var(--muted); padding: .3rem 0; color: var(--muted); padding: .3rem 0;
line-height: 1.6; /* wie die Nav-Links, sonst hängt der Button tiefer */
transition: color .2s, text-shadow .2s; transition: color .2s, text-shadow .2s;
} }
.nav-drop-btn:hover { color: var(--text); } .nav-drop-btn:hover { color: var(--text); }
@@ -1320,15 +1321,21 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
} }
/* ── LIGHTBOX ──────────────────────────────────────── */ /* ── LIGHTBOX ──────────────────────────────────────── */
/* z-index über Noise/Scanlines (900); rendert per Portal direkt in <body> */
.lightbox { .lightbox {
position: fixed; inset: 0; z-index: 1000; position: fixed; inset: 0; z-index: 1200;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, .92); background: rgba(0, 0, 0, .95);
animation: lbIn .18s ease both; animation: lbIn .18s ease both;
} }
@keyframes lbIn { from { opacity: 0; } to { opacity: 1; } } @keyframes lbIn { from { opacity: 0; } to { opacity: 1; } }
.lb-row {
display: flex; align-items: center; gap: 1.2rem;
max-width: 96vw;
}
.lb-figure { display: flex; flex-direction: column; align-items: center; margin: 0; min-width: 0; }
.lb-img { .lb-img {
max-width: min(92vw, 1400px); max-height: 84vh; max-width: 100%; max-height: 78vh; object-fit: contain;
border: 1px solid var(--border); border-radius: 8px; border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 24px 80px rgba(0, 0, 0, .8); box-shadow: 0 24px 80px rgba(0, 0, 0, .8);
} }
@@ -1340,24 +1347,23 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
} }
.lb-close:hover { color: var(--neon2); border-color: var(--neon2); } .lb-close:hover { color: var(--neon2); border-color: var(--neon2); }
.lb-nav { .lb-nav {
position: absolute; top: 50%; transform: translateY(-50%); flex: none;
background: rgba(0, 0, 0, .5); border: 1px solid var(--border); border-radius: 999px; background: rgba(255, 255, 255, .04); border: 1px solid var(--border); border-radius: 999px;
color: var(--text); font-size: 1.8rem; line-height: 1; color: var(--text); font-size: 1.8rem; line-height: 1;
width: 3rem; height: 3rem; cursor: pointer; width: 3rem; height: 3rem; cursor: pointer;
transition: color .15s, border-color .15s; transition: color .15s, border-color .15s, background .15s;
} }
.lb-nav:hover { color: var(--neon); border-color: var(--neon); } .lb-nav:hover { color: var(--neon); border-color: var(--neon); background: rgba(245, 197, 24, .08); }
.lb-prev { left: 1.2rem; }
.lb-next { right: 1.2rem; }
.lb-meta { .lb-meta {
position: absolute; bottom: 1.4rem; left: 50%; transform: translateX(-50%);
display: flex; gap: 1.2rem; align-items: baseline; display: flex; gap: 1.2rem; align-items: baseline;
font-family: var(--mono); font-size: .72rem; letter-spacing: .12em; font-family: var(--mono); font-size: .72rem; letter-spacing: .12em;
color: var(--muted); background: rgba(0, 0, 0, .6); color: var(--muted); margin-top: .8rem; white-space: nowrap;
border: 1px solid rgba(255, 255, 255, .08); border-radius: 999px;
padding: .4rem 1rem; white-space: nowrap;
} }
.lb-count { color: var(--neon); } .lb-count { color: var(--neon); }
@media (max-width: 640px) {
.lb-row { gap: .5rem; }
.lb-nav { width: 2.4rem; height: 2.4rem; font-size: 1.4rem; }
}
/* ── SKELETON-LOADER ───────────────────────────────── */ /* ── SKELETON-LOADER ───────────────────────────────── */
.skel { .skel {
@@ -1378,24 +1384,53 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
.skel-row { height: 2.6rem; border-radius: 10px; margin-bottom: .5rem; animation: riseIn .3s ease both; } .skel-row { height: 2.6rem; border-radius: 10px; margin-bottom: .5rem; animation: riseIn .3s ease both; }
/* ── SERVER-STATUS-SEITE ───────────────────────────── */ /* ── SERVER-STATUS-SEITE ───────────────────────────── */
.srv-card { margin-bottom: 1.3rem; } .srv-summary {
display: flex; gap: 2.5rem; flex-wrap: wrap;
background: var(--bg2); border: 1px solid var(--border); border-radius: 12px;
padding: 1.1rem 1.6rem; margin-bottom: 1.5rem;
}
.srv-sum-item { display: flex; flex-direction: column; }
.srv-sum-n {
font-family: var(--display); font-size: 2.2rem; line-height: 1;
color: var(--neon); text-shadow: 0 0 16px rgba(245, 197, 24, .3);
}
.srv-sum-of { font-size: 1.2rem; color: var(--muted2); text-shadow: none; }
.srv-sum-label {
font-family: var(--mono); font-size: .62rem; letter-spacing: .18em;
text-transform: uppercase; color: var(--muted); margin-top: .35rem;
}
.srv-grid { display: grid; grid-template-columns: 1fr; gap: 1.2rem; }
@media (min-width: 980px) { .srv-grid { grid-template-columns: 1fr 1fr; } }
.srv-card { margin-bottom: 0; }
.srv-card.srv-down { border-left: 3px solid rgba(242, 63, 67, .6); } .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-head { display: flex; align-items: center; gap: 1rem; }
.srv-name { font-family: var(--display); font-size: 1.5rem; letter-spacing: .04em; } .srv-logo {
.srv-status { font-family: var(--mono); font-size: .78rem; letter-spacing: .08em; color: var(--muted); } width: 52px; height: 52px; flex: none;
border-radius: 12px; object-fit: cover;
border: 1px solid var(--border); background: var(--bg3);
}
.srv-logo-emoji { display: flex; align-items: center; justify-content: center; font-size: 1.7rem; }
.srv-title { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.srv-name { font-family: var(--display); font-size: 1.5rem; letter-spacing: .04em; line-height: 1.1; }
.srv-status { font-family: var(--mono); font-size: .72rem; letter-spacing: .08em; color: var(--muted); }
.srv-status.on { color: var(--success); } .srv-status.on { color: var(--success); }
.srv-status.off { color: #f23f43; } .srv-status.off { color: #f23f43; }
.srv-ping { color: var(--muted2); } .srv-ping, .srv-map { color: var(--muted2); }
.srv-count { font-family: var(--display); font-size: 1.9rem; line-height: 1; color: var(--neon); white-space: nowrap; }
.srv-count-max { font-size: 1.1rem; color: var(--muted2); }
.srv-bar { margin-top: .9rem; } .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 { width: 100%; height: 90px; margin-top: 1rem; display: block; }
.srv-chart-label { .srv-stats {
display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
font-family: var(--mono); font-size: .62rem; letter-spacing: .15em; font-family: var(--mono); font-size: .62rem; letter-spacing: .15em;
text-transform: uppercase; color: var(--muted2); margin-top: .25rem; text-transform: uppercase; color: var(--muted2); margin-top: .25rem;
} }
.srv-stats b { color: var(--text); font-weight: 400; }
.srv-stats .up-good { color: var(--success); }
.srv-stats .up-bad { color: #f23f43; }
.srv-connect { display: flex; gap: .8rem; align-items: center; flex-wrap: wrap; margin-top: 1rem; } .srv-connect { display: flex; gap: .8rem; align-items: center; flex-wrap: wrap; margin-top: 1rem; }
.srv-addr { font-size: .8rem; } .srv-addr { font-size: .8rem; cursor: pointer; transition: border-color .15s, color .15s; }
.srv-addr:hover { border-color: var(--neon); color: var(--neon); }
.srv-checked { font-family: var(--mono); font-size: .62rem; color: var(--muted2); margin-top: .8rem; } .srv-checked { font-family: var(--mono); font-size: .62rem; color: var(--muted2); margin-top: .8rem; }
/* ── AUDIT-LOG ─────────────────────────────────────── */ /* ── AUDIT-LOG ─────────────────────────────────────── */
@@ -1403,3 +1438,31 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
.audit-ts { font-family: var(--mono); font-size: .65rem; color: var(--muted2); white-space: nowrap; } .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-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; } .audit-detail { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 18rem; }
/* ── STARTSEITE ────────────────────────────────────── */
.home-hero { min-height: 420px; }
.home-accent { color: var(--neon2); }
.home-cta { display: flex; gap: .9rem; flex-wrap: wrap; margin-top: 1.6rem; }
.home-cta-alt { border: 1px solid var(--border); }
.home-stats { margin-bottom: 1.5rem; }
.home-tiles {
display: grid; gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.home-tile {
display: flex; flex-direction: column; gap: .3rem;
padding: 1.3rem 1.4rem;
}
.home-tile-icon { font-size: 1.6rem; line-height: 1; margin-bottom: .3rem; }
.home-tile-title {
font-family: var(--display); font-size: 1.4rem; letter-spacing: .05em;
color: var(--text); transition: color .2s;
}
.home-tile:hover .home-tile-title { color: var(--neon); }
.home-tile-desc { font-weight: 300; color: var(--muted); font-size: .95rem; line-height: 1.4; }
.home-devlog { display: block; }
.home-devlog-more {
display: inline-block; margin-top: .8rem;
font-family: var(--mono); font-size: .7rem; letter-spacing: .2em;
text-transform: uppercase; color: var(--neon);
}
+1
View File
@@ -87,6 +87,7 @@ export function buildServerEmbed(r) {
.addFields({ name: 'Status', value: r.online ? '🟢 **Online**' : '🔴 **Offline**', inline: true }) .addFields({ name: 'Status', value: r.online ? '🟢 **Online**' : '🔴 **Offline**', inline: true })
.setFooter({ text: `${brandFooter('STATUS')}${r.ping != null ? `${r.ping}ms` : ''} • alle 2 min` }) .setFooter({ text: `${brandFooter('STATUS')}${r.ping != null ? `${r.ping}ms` : ''} • alle 2 min` })
.setTimestamp(); .setTimestamp();
if (r.image_url) embed.setThumbnail(r.image_url);
if (r.online && r.players != null && r.max) { if (r.online && r.players != null && r.max) {
const pct = Math.min(1, r.players / r.max); const pct = Math.min(1, r.players / r.max);
+4 -3
View File
@@ -531,14 +531,15 @@ db.exec(`
if (!cols.includes('host')) db.exec(`ALTER TABLE gameservers ADD COLUMN host TEXT NOT NULL DEFAULT ''`); if (!cols.includes('host')) db.exec(`ALTER TABLE gameservers ADD COLUMN host TEXT NOT NULL DEFAULT ''`);
if (!cols.includes('port')) db.exec('ALTER TABLE gameservers ADD COLUMN port INTEGER'); if (!cols.includes('port')) db.exec('ALTER TABLE gameservers ADD COLUMN port INTEGER');
if (!cols.includes('connect_url')) db.exec(`ALTER TABLE gameservers ADD COLUMN connect_url TEXT NOT NULL DEFAULT ''`); if (!cols.includes('connect_url')) db.exec(`ALTER TABLE gameservers ADD COLUMN connect_url TEXT NOT NULL DEFAULT ''`);
if (!cols.includes('image_url')) db.exec(`ALTER TABLE gameservers ADD COLUMN image_url TEXT NOT NULL DEFAULT ''`);
} }
const insertGameserver = db.prepare(` const insertGameserver = db.prepare(`
INSERT INTO gameservers (name, type, query_url, address, host, port, connect_url) INSERT INTO gameservers (name, type, query_url, address, host, port, connect_url, image_url)
VALUES (@name, @type, @query_url, @address, @host, @port, @connect_url) VALUES (@name, @type, @query_url, @address, @host, @port, @connect_url, @image_url)
`); `);
const updateGameserverStmt = db.prepare(` const updateGameserverStmt = db.prepare(`
UPDATE gameservers SET name = @name, type = @type, query_url = @query_url, address = @address, UPDATE gameservers SET name = @name, type = @type, query_url = @query_url, address = @address,
host = @host, port = @port, connect_url = @connect_url host = @host, port = @port, connect_url = @connect_url, image_url = @image_url
WHERE id = @id WHERE id = @id
`); `);
const setGameserverMessageStmt = db.prepare('UPDATE gameservers SET message_id = ? WHERE id = ?'); const setGameserverMessageStmt = db.prepare('UPDATE gameservers SET message_id = ? WHERE id = ?');
+4
View File
@@ -134,6 +134,7 @@ export function registerApiRoutes(app, client) {
name: s.name, name: s.name,
type: s.type, type: s.type,
icon: GAME_ICONS[s.type] ?? '🎮', icon: GAME_ICONS[s.type] ?? '🎮',
image_url: s.image_url || null,
address: s.address || null, address: s.address || null,
connect_url: s.connect_url || null, connect_url: s.connect_url || null,
online: r?.online ?? null, // null = noch nie gecheckt online: r?.online ?? null, // null = noch nie gecheckt
@@ -818,6 +819,9 @@ ${rssItems}
host: host.slice(0, 120), host: host.slice(0, 120),
port, port,
connect_url: String(body.connect_url ?? '').trim().slice(0, 200), connect_url: String(body.connect_url ?? '').trim().slice(0, 200),
image_url: /^https:\/\/.+/.test(String(body.image_url ?? '').trim())
? String(body.image_url).trim().slice(0, 300)
: '',
}; };
} }