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>
75 lines
3.2 KiB
TypeScript
75 lines
3.2 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|