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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Öffentliche Server-Status-Seite: Live-Status + 24h-Spielerzahl-Verlauf
|
||||
// (Daten sammelt der Server-Monitor alle 2 Minuten).
|
||||
// Öffentliche Server-Status-Seite: Übersichtsleiste, Karte pro Server mit Logo,
|
||||
// Live-Status, 24h-Spielerzahl-Verlauf, Uptime und Peak.
|
||||
// Daten sammelt der Server-Monitor alle 2 Minuten.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiGet } from '../api.js';
|
||||
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 }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
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 (
|
||||
<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>
|
||||
{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' : ''}`}>
|
||||
{s.online === null ? '⏳ Warte auf Check' : s.online ? '🟢 Online' : '🔴 Offline'}
|
||||
{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>
|
||||
</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 && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
<div className="xp-bar srv-bar">
|
||||
<div className="xp-fill" style={{ width: `${Math.min(100, pct)}%` }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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) && (
|
||||
<div className="srv-connect">
|
||||
{s.connect_url && (
|
||||
<a className="btn btn-save" href={s.connect_url}>Connect</a>
|
||||
{s.connect_url && <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>
|
||||
)}
|
||||
{s.checkedAt && (
|
||||
@@ -89,6 +129,9 @@ export default function Server() {
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const online = servers?.filter((s) => s.online) ?? [];
|
||||
const totalPlayers = online.reduce((sum, s) => sum + (s.players ?? 0), 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-header">
|
||||
@@ -107,7 +150,23 @@ export default function Server() {
|
||||
{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} />)}
|
||||
|
||||
{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} />)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -154,7 +154,7 @@ export default function Settings({ me }) {
|
||||
['terraria', '🌳 Terraria'], ['arma3', '🪖 Arma 3'], ['squad', '🪖 Squad'],
|
||||
['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 [serverDraft, setServerDraft] = useState(emptyServer);
|
||||
// Bot-Beschreibung + Team
|
||||
@@ -1372,6 +1372,7 @@ export default function Settings({ me }) {
|
||||
<button className="btn btn-mini" onClick={() => setServerDraft({
|
||||
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 ?? '',
|
||||
image_url: s.image_url ?? '',
|
||||
})}>Bearbeiten</button>
|
||||
<button className="card-delete" title="Server + Embed entfernen" onClick={() => removeServer(s.id)}>✕</button>
|
||||
</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="connect play.d4rkst3r.de" value={serverDraft.address} onChange={(e) => setServerDraft({ ...serverDraft, address: e.target.value })} />
|
||||
</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' }}>
|
||||
<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>}
|
||||
|
||||
Reference in New Issue
Block a user