Files
d4rkbot/frontend/src/pages/Server.jsx
T
D4rkst3randClaude Opus 5 b69cb6c5e4
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Freie Links je Server statt fester Felder
connect_url und mods_url waren zwei Spalten fuer zwei Zwecke — und beim
naechsten Zweck waere eine dritte dazugekommen. Jetzt gibt es eine Liste:
Beschriftung plus Adresse, so viele wie noetig. Regeln, TeamSpeak, Shop,
Bewerbung.

Als JSON-Spalte, nicht als eigene Tabelle: es haengt an genau einem Server,
wird immer komplett gelesen und immer komplett geschrieben. Gelesen wird es als
Liste, geschrieben darf es als Liste hereinkommen — better-sqlite3 kann kein
Array binden und wuerde sonst werfen. Kaputtes JSON ergibt eine leere Liste
statt eines Absturzes im Monitor.

Discord nimmt fuenf Knoepfe je Reihe; ab jetzt werden sie umgebrochen, bei
zwei Reihen ist Schluss. Die Grenze steht im Code und nicht im Fehler beim
Senden, den niemand sieht. Zu lange Beschriftungen werden auf 80 Zeichen
gekuerzt, halbe Zeilen fliegen raus statt als leerer Knopf zu erscheinen. Was
nicht http(s) ist — ts3server://, steam:// —, wird wie bisher ein Codeblock im
Embed zum Kopieren.

Dabei umbenannt: das Embed-Feld fuer einen nicht klickbaren Connect-Link hiess
"Connect", der Knopf daneben "Verbinden". Jetzt heisst beides gleich.

Geprueft: Umbruch bei zwoelf Links, Reihenfolge mit Connect und Mods vorn,
halbe Zeilen, ueberlange Beschriftung, nicht klickbare Schemata, fehlendes und
unbrauchbares links-Feld, dazu Speichern, Aendern und kaputtes JSON in der
Datenbank. Panel und /server-Seite im laufenden Frontend angesehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 13:39:10 +02:00

278 lines
13 KiB
React

// Ö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';
import { IconGame, IconMapPin, IconClock, IconCheck, IconTeam, IconPackage, IconZap, IconDownload, IconArchive } from '../icons.jsx';
import { useT, useFmt } from '../i18n.jsx';
import UptimeTrack from '../components/UptimeTrack.jsx';
const UHRZEIT = { hour: '2-digit', minute: '2-digit' };
/** "vor 3 Std." — grob genug, dass es nicht jede Minute anders aussieht */
function vorWieLange(iso, t) {
// SQLite liefert "YYYY-MM-DD HH:MM:SS" ohne Zeitzone; das ist UTC.
const ts = Date.parse(iso.includes('T') ? iso : `${iso.replace(' ', 'T')}Z`);
if (Number.isNaN(ts)) return null;
const min = Math.max(1, Math.round((Date.now() - ts) / 60000));
if (min < 60) return t('server.agoMin', min);
const std = Math.round(min / 60);
if (std < 48) return t('server.agoHours', std);
return t('server.agoDays', Math.round(std / 24));
}
/** 24h-Verlauf als SVG-Fläche; Offline-Samples als rote Punkte auf der Nulllinie.
* Der Peak bekommt eine eigene Linie — ohne die ist eine Fläche ohne Achse nur
* Dekoration und man kann nicht sagen, wie hoch „hoch" eigentlich ist. */
function HistoryChart({ history, max }) {
if (history.length < 2) return null;
const W = 600, H = 100, PAD = 4;
const spitze = Math.max(0, ...history.map((h) => (h.online ? h.players ?? 0 : 0)));
const peak = Math.max(max ?? 0, 1, spitze);
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);
const spitzeY = y(spitze);
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>
{/* Peak-Linie: der Bezugspunkt für alles darunter */}
{spitze > 0 && (
<line
className="srv-chart-peak" x1={PAD} x2={W - PAD} y1={spitzeY} y2={spitzeY}
strokeDasharray="3 5"
/>
)}
<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>
);
}
/** Spieler-Peak aus den 24h-Samples (Uptime kommt aus dem Balken oben) */
function historyStats(history) {
if (history.length === 0) return null;
return { peak: Math.max(0, ...history.map((h) => h.players ?? 0)) };
}
function ServerCard({ s }) {
const { t } = useT();
const { datum } = useFmt();
const [copied, setCopied] = useState(false);
const [alleSpieler, setAlleSpieler] = useState(false);
const pct = s.online && s.max ? Math.round((s.players / s.max) * 100) : 0;
const stats = historyStats(s.history);
// War in 24 h überhaupt jemand drauf? Wenn nicht, ist das Diagramm eine
// Nulllinie in hundert Pixeln Leere — das sieht nach Fehler aus, obwohl
// es keiner ist. Dann lieber ein Satz statt einer leeren Fläche.
const hatVerlauf = s.history.length > 1 && s.history.some((h) => (h.players ?? 0) > 0);
// Ab 80 % wird es eng, ab 100 % kommt keiner mehr rein — das soll man
// sehen, ohne den Balken mit der Zahl daneben zu vergleichen.
const fuellung = pct >= 100 ? 'voll' : pct >= 80 ? 'eng' : 'frei';
const namen = s.spielerNamen ?? [];
const gezeigt = alleSpieler ? namen : namen.slice(0, 8);
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">
{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
? <><IconClock size={13} /> {t('server.waiting')}</>
: <><span className="srv-dot" /> {s.online ? t('server.online') : t('server.offline')}</>}
{/* Das Spiel gehört zum Server, nicht zum Zustand — es
steht auch dann noch da, wenn nichts antwortet. */}
{s.spiel && <span className="srv-spiel">{s.spiel}</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={`srv-cap ${fuellung}`}>
<div className="srv-cap-fill" style={{ width: `${Math.min(100, pct)}%` }} />
<span className="srv-cap-pct">{pct}%</span>
</div>
)}
{/* Was der Server über sich verrät — nur, was auch da ist */}
{s.online && (s.map || s.ping != null || s.version) && (
<div className="srv-chips">
{s.map && <span className="srv-chip"><IconMapPin size={12} /> {s.map}</span>}
{s.version && <span className="srv-chip"><IconPackage size={12} /> {s.version}</span>}
{/* Eigenes Zeichen — mit dem Paket-Icon der Version daneben
sähen die beiden Chips wie zusammengehörig aus. */}
{s.mods > 0 && <span className="srv-chip"><IconArchive size={12} /> {t('server.mods', s.mods)}</span>}
{s.ping != null && <span className="srv-chip"><IconZap size={12} /> {s.ping} ms</span>}
</div>
)}
{/* Offline: die eine Frage, die dann zählt */}
{s.online === false && (
<p className="srv-weg">
{s.zuletztOnline
? t('server.lastOnline', vorWieLange(s.zuletztOnline, t) ?? '—')
: t('server.neverOnline')}
</p>
)}
{namen.length > 0 && (
<div className="srv-spieler">
<span className="srv-spieler-kopf"><IconTeam size={13} /> {t('server.whoIsOn')}</span>
<div className="srv-namen">
{gezeigt.map((n) => <span className="srv-name-chip" key={n}>{n}</span>)}
{!alleSpieler && namen.length > gezeigt.length && (
<button className="srv-mehr" onClick={() => setAlleSpieler(true)}>
{t('server.andMore', namen.length - gezeigt.length)}
</button>
)}
</div>
</div>
)}
{hatVerlauf ? (
<>
<HistoryChart history={s.history} max={s.max} />
<div className="srv-stats">
<span>{t('server.players24h')}</span>
{/* Uptime steht jetzt am Balken darunter — zweimal dieselbe
Zahl in verschiedenen Zeiträumen verwirrt nur. */}
<span>{t('server.peak')} <b>{stats.peak}</b></span>
</div>
<div className="srv-achse">
<span>{t('server.hours24')}</span>
<span>{t('server.now')}</span>
</div>
</>
) : s.online && (
<p className="srv-leer">{t('server.nobody24h')}</p>
)}
<UptimeTrack buckets={s.uptime} />
{(s.connect_url || s.mods_url || s.links?.length > 0 || s.address) && (
<div className="srv-connect">
{s.connect_url && (
<a className="btn btn-save with-icon" href={s.connect_url}>
<IconGame size={15} /> {t('server.connect')}
</a>
)}
{s.mods_url && (
<a className="btn btn-ghost with-icon" href={s.mods_url} rel="noreferrer">
<IconDownload size={15} /> {t('server.modsDownload')}
</a>
)}
{/* Frei eingetragene Links. `noreferrer` weil sie überall
hinführen können — das ist ja der Sinn. */}
{(s.links ?? []).map((l) => (
<a className="btn btn-ghost" key={`${l.label}${l.url}`} href={l.url} rel="noreferrer">
{l.label}
</a>
))}
{s.address && (
<button className="alpha-key srv-addr" onClick={copyAddress} title={t('server.copyAddress')}>
{copied ? <span className="with-icon"><IconCheck size={14} /> {t('server.copied')}</span> : s.address}
</button>
)}
</div>
)}
{s.checkedAt && (
<p className="srv-checked">{t('server.checkedAt', datum(s.checkedAt, UHRZEIT))}</p>
)}
</article>
);
}
export default function Server() {
const { t } = useT();
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);
}, []);
const online = servers?.filter((s) => s.online) ?? [];
const totalPlayers = online.reduce((sum, s) => sum + (s.players ?? 0), 0);
const totalSlots = online.reduce((sum, s) => sum + (s.max ?? 0), 0);
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">{t('server.tag')}</div>
<h1 className="page-title">{t('nav.server')}</h1>
<p className="page-subtitle">{t('server.subtitle')}</p>
</div>
</section>
<section className="content">
{error && <p className="notice">{t('server.error')}</p>}
{!error && !servers && <SkeletonCards n={2} />}
{servers?.length === 0 && <p className="notice">{t('server.none')}</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">{t('server.serversOnline')}</span>
</div>
<div className="srv-sum-item">
<span className="srv-sum-n">{totalPlayers}</span>
<span className="srv-sum-label">{t('server.playersNow')}</span>
</div>
{/* Nur wenn überhaupt jemand eine Obergrenze meldet —
ein HTTP-Check hat keine Plätze. */}
{totalSlots > 0 && (
<div className="srv-sum-item">
<span className="srv-sum-n">{totalSlots}</span>
<span className="srv-sum-label">{t('server.slots')}</span>
</div>
)}
</div>
)}
<div className="srv-grid">
{servers?.map((s) => <ServerCard s={s} key={s.id} />)}
</div>
</section>
</>
);
}