feat: ATM10-Statistikportal

Statistikportal für den All-the-Mods-10-Server (Minecraft 1.21.1, NeoForge 21.1):

- mod/: serverseitige NeoForge-Mod atmstats – Ereignisse, Live-Zähler, Servergesundheit,
  FTB-Dateien; Outbox auf der Festplatte, signierter gzip-Versand per HTTPS mit Backoff
- backend/: Fastify + PostgreSQL – idempotenter Ingest, Projektionen, Parser für SNBT,
  FTB Quests/Teams, world/stats, world/advancements und latest.log; Ranglisten, Snapshots,
  Retention, öffentliche API, SSE-Live-Updates, Admin mit argon2/CSRF, SFTP-Worker
- web/: Next.js-Dashboard – Übersicht, Ranglisten, Spielerprofile, ATM10, Server,
  Datenquellen, Verwaltung
- shared/: Protokoll v1 und API-Typen; tools/: Simulator, Log-Anonymisierer, RCON-Client
- deploy/: Docker Compose hinter Traefik, Backup; docs/: Architektur, Datenquellen, Betrieb

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lucashahmannandClaude Opus 5.5 committed 2026-10-13 09:33:25 +02:00
1 parent 56d2078e6a
commit e66f89f49e
214 files changed
+27077

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
import type { LeaderboardDto, Period } from '@atm/shared/api';
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { PeriodTabs } from '@/components/PeriodTabs';
import { PlayerHead } from '@/components/PlayerHead';
import { ApiDown, EmptyState, PageTitle } from '@/components/States';
import { apiFind } from '@/lib/api';
import { formatUnit } from '@/lib/format';
export const dynamic = 'force-dynamic';
type Props = { params: Promise<{ board: string }>; searchParams: Promise<{ period?: string }> };
const PERIOD_TEXT: Record<Period, string> = {
all: 'über die gesamte Spielzeit',
'30d': 'Zuwachs der letzten 30 Tage',
'7d': 'Zuwachs der letzten 7 Tage',
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { board } = await params;
const data = await apiFind<LeaderboardDto>(`/api/leaderboards/${encodeURIComponent(board)}`).catch(() => null);
return { title: data?.board.title ?? 'Rangliste' };
}
export default async function BoardPage({ params, searchParams }: Props) {
const { board } = await params;
const raw = (await searchParams).period;
const period: Period = raw === '7d' || raw === '30d' ? raw : 'all';
let data: LeaderboardDto | null;
try {
data = await apiFind<LeaderboardDto>(`/api/leaderboards/${encodeURIComponent(board)}?period=${period}`);
} catch {
return <ApiDown />;
}
if (!data) notFound();
const max = Math.max(...data.entries.map((e) => e.value), 1);
return (
<>
<p className="text-sm mb-2">
<Link href="/leaderboards" className="text-stone-3 hover:text-stone">Alle Ranglisten</Link>
</p>
<PageTitle title={data.board.title}>
{data.board.description}, {PERIOD_TEXT[period]}.
</PageTitle>
<div className="mb-6">
<PeriodTabs base={`/leaderboards/${data.board.id}`} active={period} />
</div>
{data.entries.length === 0 ? (
<EmptyState title="Noch keine Einträge">
{period === 'all' ? 'Sobald Spieler Werte in dieser Kategorie haben, erscheinen sie hier.' : 'In diesem Zeitraum hat sich niemand verbessert.'}
</EmptyState>
) : (
<ol className="rounded-lg bg-slate-1 border border-slate-2 divide-y divide-slate-2">
{data.entries.map((e) => (
<li key={e.uuid} className="flex items-center gap-3 px-4 py-2.5">
<span className={`w-8 text-right font-semibold tabular-nums ${e.rank <= 3 ? 'text-gold' : 'text-stone-3'}`}>{e.rank}</span>
<PlayerHead uuid={e.uuid} name={e.name} size={28} />
<Link href={`/players/${e.name ?? e.uuid}`} className="w-40 truncate hover:text-gold">
{e.name ?? 'Unbekannt'}
</Link>
<span className="hidden sm:block flex-1 h-1.5 rounded-full bg-slate-2 overflow-hidden" aria-hidden>
<span className="block h-full rounded-full bg-gold-dim" style={{ width: `${(e.value / max) * 100}%` }} />
</span>
<span className="ml-auto sm:ml-0 sm:w-28 text-right tabular-nums">{formatUnit(e.value, data.board.unit)}</span>
</li>
))}
</ol>
)}
</>
);
}