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

+43
View File
@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/test-results/
/playwright-report/
+28
View File
@@ -0,0 +1,28 @@
# Build context: repository root (docker build -f web/Dockerfile .)
FROM node:22-alpine AS build
WORKDIR /repo
COPY package.json package-lock.json ./
COPY shared/package.json shared/
COPY backend/package.json backend/
COPY web/package.json web/
COPY tools/simulator/package.json tools/simulator/
RUN npm ci --workspace web --include-workspace-root
COPY tsconfig.base.json ./
COPY shared shared
COPY web web
ENV NEXT_TELEMETRY_DISABLED=1
# Rewrites are resolved at build time; inside Compose the API is reachable as "api".
ARG API_INTERNAL_URL=http://api:3001
ENV API_INTERNAL_URL=$API_INTERNAL_URL
RUN npm run build -w web
FROM node:22-alpine
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0 API_INTERNAL_URL=http://api:3001
WORKDIR /app
# Standalone output mirrors the monorepo layout: server.js lives in web/.
COPY --from=build --chown=node:node /repo/web/.next/standalone ./
COPY --from=build --chown=node:node /repo/web/.next/static ./web/.next/static
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD wget -qO- http://127.0.0.1:3000/ >/dev/null || exit 1
CMD ["node", "web/server.js"]
+47
View File
@@ -0,0 +1,47 @@
import { expect, test } from '@playwright/test';
test('overview shows status, totals and leaderboards', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Spieler online')).toBeVisible();
await expect(page.getByText('Spielzeit gesamt')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Bestenlisten' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Dauerzocker' })).toBeVisible();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth);
expect(overflow).toBe(false);
});
test('clicking a player opens the profile', async ({ page }) => {
await page.goto('/leaderboards/playtime');
const first = page.locator('ol li a').first();
const name = (await first.textContent())!.trim();
await first.click();
await expect(page.getByRole('heading', { level: 1, name })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Wann gespielt wird' })).toBeVisible();
});
test('leaderboard periods switch', async ({ page }) => {
await page.goto('/leaderboards/deaths');
await page.getByRole('link', { name: '7 Tage' }).click();
await expect(page).toHaveURL(/period=7d/);
await expect(page.getByText('Zuwachs der letzten 7 Tage', { exact: false })).toBeVisible();
});
test('unknown player shows the not-found page', async ({ page }) => {
const res = await page.goto('/players/DiesenSpielerGibtEsNicht');
expect(res?.status()).toBe(404);
await expect(page.getByText('Hier gibt es nichts abzubauen')).toBeVisible();
});
test('live endpoint streams server-sent events', async ({ page }) => {
await page.goto('/');
const first = await page.evaluate(async () => {
const ctrl = new AbortController();
const res = await fetch('/api/live', { signal: ctrl.signal });
const reader = res.body!.getReader();
const { value } = await reader.read();
ctrl.abort();
return { type: res.headers.get('content-type'), text: new TextDecoder().decode(value) };
});
expect(first.type).toContain('text/event-stream');
expect(first.text).toContain(': connected');
});
+25
View File
@@ -0,0 +1,25 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;
+31
View File
@@ -0,0 +1,31 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { NextConfig } from 'next';
const api = process.env.API_INTERNAL_URL ?? 'http://127.0.0.1:3001';
const nextConfig: NextConfig = {
output: 'standalone',
// Trace from the monorepo root so the workspace package @atm/shared is included.
outputFileTracingRoot: join(dirname(fileURLToPath(import.meta.url)), '..'),
transpilePackages: ['@atm/shared'],
poweredByHeader: false,
// In production Traefik routes /api directly to the API; the rewrite covers local development.
async rewrites() {
return [{ source: '/api/:path*', destination: `${api}/api/:path*` }];
},
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'X-Frame-Options', value: 'DENY' },
],
},
];
},
};
export default nextConfig;
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@atm/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "eslint",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@atm/shared": "*",
"next": "15.5.27",
"react": "19.1.0",
"react-dom": "19.1.0",
"recharts": "^2.15.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@playwright/test": "^1.64.0",
"@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.3",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9",
"eslint-config-next": "15.5.27",
"jsdom": "^25.0.1",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^3.2.7"
}
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig, devices } from '@playwright/test';
// Smoke test against a running stack (docker compose … up, simulator data loaded):
// E2E_BASE_URL=http://localhost:3000 npx playwright test -c web/playwright.config.ts
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
use: { baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000', colorScheme: 'dark' },
projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile', use: { ...devices['Pixel 7'] } },
],
});
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
+219
View File
@@ -0,0 +1,219 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { PlayerHead } from '@/components/PlayerHead';
import { Section } from '@/components/Section';
import { formatDateTime, formatRelative } from '@/lib/format';
interface Status {
push: { lastEventAt: number | null };
sftp: { enabled: boolean; lastSyncAt: number | null; lastEventAt: number | null; pendingRequests: number };
events: number;
activeTokens: number;
lastErrors: { at: number; source: string; message: string }[];
}
interface AdminPlayer {
uuid: string;
name: string | null;
hidden: boolean;
lastSeen: number | null;
}
async function call<T>(path: string, init: RequestInit & { csrf?: string } = {}): Promise<T> {
const headers: Record<string, string> = { 'content-type': 'application/json' };
if (init.csrf) headers['x-csrf-token'] = init.csrf;
const res = await fetch(path, { ...init, headers, credentials: 'same-origin' });
if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { status: res.status });
return (await res.json()) as T;
}
export function AdminPanel() {
const [session, setSession] = useState<{ username: string; csrf: string } | null | undefined>(undefined);
useEffect(() => {
call<{ username: string; csrf: string }>('/api/admin/session')
.then(setSession)
.catch(() => setSession(null));
}, []);
if (session === undefined) return <p className="text-stone-3">Lade …</p>;
if (session === null) return <Login onLogin={setSession} />;
return <Dashboard session={session} onLogout={() => setSession(null)} />;
}
function Login({ onLogin }: { onLogin: (s: { username: string; csrf: string }) => void }) {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
setBusy(true);
setError(null);
try {
const s = await call<{ username: string; csrf: string }>('/api/admin/login', {
method: 'POST',
body: JSON.stringify({ username: form.get('username'), password: form.get('password') }),
});
onLogin(s);
} catch (err) {
const status = (err as { status?: number }).status;
setError(status === 429 ? 'Zu viele Versuche. Warte eine Minute.' : 'Benutzername oder Passwort ist falsch.');
} finally {
setBusy(false);
}
}
return (
<div className="max-w-sm mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">Verwaltung</h1>
<form onSubmit={submit} className="space-y-4">
<label className="block">
<span className="text-sm text-stone-2">Benutzername</span>
<input name="username" autoComplete="username" required className="mt-1 w-full rounded-md bg-slate-1 border border-slate-3 px-3 py-2" />
</label>
<label className="block">
<span className="text-sm text-stone-2">Passwort</span>
<input name="password" type="password" autoComplete="current-password" required className="mt-1 w-full rounded-md bg-slate-1 border border-slate-3 px-3 py-2" />
</label>
{error && (
<p role="alert" className="text-sm text-danger">
{error}
</p>
)}
<button disabled={busy} className="w-full rounded-md bg-gold text-slate-0 font-semibold py-2 disabled:opacity-60">
{busy ? 'Anmelden …' : 'Anmelden'}
</button>
</form>
<p className="text-xs text-stone-3 mt-6">Admin-Konten werden auf dem Server mit „npm run cli -- admin:create &lt;name&gt;“ angelegt.</p>
</div>
);
}
function Dashboard({ session, onLogout }: { session: { username: string; csrf: string }; onLogout: () => void }) {
const [status, setStatus] = useState<Status | null>(null);
const [players, setPlayers] = useState<AdminPlayer[]>([]);
const [token, setToken] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const now = Date.now();
const load = useCallback(async () => {
try {
const [s, p] = await Promise.all([call<Status>('/api/admin/status'), call<{ players: AdminPlayer[] }>('/api/admin/players')]);
setStatus(s);
setPlayers(p.players);
} catch (err) {
if ((err as { status?: number }).status === 401) onLogout();
}
}, [onLogout]);
useEffect(() => {
void load();
}, [load]);
const post = (path: string, body?: unknown) =>
call(path, { method: 'POST', csrf: session.csrf, body: body === undefined ? undefined : JSON.stringify(body) });
async function toggle(p: AdminPlayer) {
await post(`/api/admin/players/${p.uuid}/hidden`, { hidden: !p.hidden });
setNotice(`${p.name ?? p.uuid} ist jetzt ${p.hidden ? 'wieder sichtbar' : 'ausgeblendet'}.`);
await load();
}
async function rotate() {
if (!window.confirm('Neues Ingest-Token erzeugen? Das alte wird sofort ungültig; die Mod muss danach das neue Token bekommen.')) return;
const r = (await post('/api/admin/ingest-token/rotate')) as { token: string };
setToken(r.token);
}
async function sync() {
await post('/api/admin/sync');
setNotice('SFTP-Abgleich angefordert. Der Worker startet ihn innerhalb von 30 Sekunden.');
await load();
}
async function logout() {
await post('/api/admin/logout').catch(() => {});
onLogout();
}
return (
<div className="space-y-6">
<div className="flex items-baseline justify-between">
<h1 className="text-3xl font-bold">Verwaltung</h1>
<button onClick={logout} className="text-sm text-stone-2 hover:text-stone">
{session.username} abmelden
</button>
</div>
{notice && (
<p role="status" className="rounded-md bg-slate-2 px-3 py-2 text-sm">
{notice}
</p>
)}
{status && (
<div className="grid sm:grid-cols-2 gap-4">
<Section title="Statistik-Mod">
<p className="text-sm text-stone-2">Letzte Meldung {formatRelative(status.push.lastEventAt, now)}.</p>
<p className="text-sm text-stone-2">
ca. {status.events.toLocaleString("de-DE")} gespeicherte Ereignisse, {status.activeTokens} aktive Tokens.
</p>
<button onClick={rotate} className="mt-4 rounded-md border border-slate-3 px-3 py-1.5 text-sm hover:border-gold">
Ingest-Token erneuern
</button>
{token && (
<div className="mt-3 rounded-md bg-slate-2 p-3 text-sm">
<p className="text-stone-2 mb-1">Neues Token, wird nur jetzt angezeigt. In config/atmstats-server.toml als token eintragen:</p>
<code className="block break-all select-all text-gold">{token}</code>
</div>
)}
</Section>
<Section title="SFTP-Abgleich">
<p className="text-sm text-stone-2">
{status.sftp.enabled ? `Letzter Abgleich ${formatRelative(status.sftp.lastSyncAt, now)}.` : 'Nicht eingerichtet (SFTP_* in der .env).'}
</p>
{status.sftp.pendingRequests > 0 && <p className="text-sm text-stone-3">{status.sftp.pendingRequests} Anforderung(en) offen.</p>}
<button
onClick={sync}
disabled={!status.sftp.enabled}
className="mt-4 rounded-md border border-slate-3 px-3 py-1.5 text-sm hover:border-gold disabled:opacity-50"
>
Jetzt abgleichen
</button>
</Section>
</div>
)}
<Section title="Letzte Fehler">
{!status?.lastErrors.length ? (
<p className="text-sm text-stone-3">Keine Fehler protokolliert.</p>
) : (
<ul className="text-sm space-y-2">
{status.lastErrors.map((e, i) => (
<li key={`${e.at}-${i}`}>
<span className="text-stone-3">{formatDateTime(e.at)}, {e.source}: </span>
<span className="break-words">{e.message}</span>
</li>
))}
</ul>
)}
</Section>
<Section title="Spieler ein- und ausblenden">
<p className="text-sm text-stone-3 mb-3">Ausgeblendete Spieler erscheinen in keiner öffentlichen Ansicht, Rangliste oder Live-Meldung.</p>
<ul className="divide-y divide-slate-2">
{players.map((p) => (
<li key={p.uuid} className="flex items-center gap-3 py-2">
<PlayerHead uuid={p.uuid} name={p.name} size={24} />
<span className={`flex-1 truncate ${p.hidden ? 'text-stone-3 line-through' : ''}`}>{p.name ?? p.uuid}</span>
<span className="text-xs text-stone-3 hidden sm:block">{formatRelative(p.lastSeen, now)}</span>
<button onClick={() => toggle(p)} className="text-sm rounded-md border border-slate-3 px-2.5 py-1 hover:border-gold">
{p.hidden ? 'Einblenden' : 'Ausblenden'}
</button>
</li>
))}
</ul>
</Section>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import type { Metadata } from 'next';
import { AdminPanel } from './AdminPanel';
export const metadata: Metadata = { title: 'Verwaltung', robots: { index: false } };
export default function AdminPage() {
return <AdminPanel />;
}
+122
View File
@@ -0,0 +1,122 @@
import type { ModsDto, QuestsDto } from '@atm/shared/api';
import type { Metadata } from 'next';
import { BarList } from '@/components/BarList';
import { Section } from '@/components/Section';
import { ApiDown, PageTitle } from '@/components/States';
import { apiGet } from '@/lib/api';
import { formatCount } from '@/lib/format';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = { title: 'ATM10' };
// Categorical slots in fixed order (validated reference palette); "other" stays neutral.
const CATEGORY = {
tech: { label: 'Technik', color: 'var(--color-series-1)' },
magic: { label: 'Magie', color: 'var(--color-series-2)' },
farming: { label: 'Farming', color: 'var(--color-series-3)' },
other: { label: 'Andere Mods', color: 'var(--color-stone-3)' },
} as const;
export default async function Atm10Page() {
let mods: ModsDto;
let quests: QuestsDto;
try {
[mods, quests] = await Promise.all([apiGet<ModsDto>('/api/mods'), apiGet<QuestsDto>('/api/quests')]);
} catch {
return <ApiDown />;
}
const active = mods.mods.filter((m) => m.activity > 0).slice(0, 20);
return (
<div className="space-y-6">
<PageTitle title="All the Mods 10">
Womit auf dem Server gearbeitet wird: Aktivität nach Bereich und Mod, abgeleitet aus abgebauten, hergestellten, benutzten und
aufgesammelten Items, sowie der Fortschritt in den FTB Quests.
</PageTitle>
<div className="grid lg:grid-cols-2 gap-6">
<Section title="Aktivität nach Bereich">
<ul className="space-y-3">
{mods.categories.map((c) => {
const meta = CATEGORY[c.category as keyof typeof CATEGORY] ?? CATEGORY.other;
const max = Math.max(...mods.categories.map((x) => x.activity), 1);
return (
<li key={c.category} className="text-sm">
<div className="flex justify-between">
<span className="flex items-center gap-2">
<span className="size-2.5 rounded-sm" style={{ background: meta.color }} aria-hidden />
{meta.label}
</span>
<span className="tabular-nums text-stone-2">{formatCount(c.activity)}</span>
</div>
<div className="h-2 mt-1 rounded-full bg-slate-2" aria-hidden>
<div className="h-full rounded-full" style={{ width: `${Math.max(2, (c.activity / max) * 100)}%`, background: meta.color }} />
</div>
</li>
);
})}
</ul>
{mods.categories.length === 0 && <p className="text-sm text-stone-3">Noch keine Item-Statistiken vorhanden.</p>}
<p className="text-xs text-stone-3 mt-4">
Zuordnung der Mods zu Bereichen: Technik (z. B. Mekanism, Powah, Create), Magie (z. B. Ars Nouveau, Iron’s Spells), Farming
(z. B. Mystical Agriculture). Vanilla-Items zählen nicht mit.
</p>
</Section>
<Section title="Meistgenutzte Mods">
<ul className="space-y-2">
{active.map((m) => {
const meta = CATEGORY[(m.category ?? 'other') as keyof typeof CATEGORY];
return (
<li key={m.id} className="text-sm">
<div className="flex justify-between gap-3">
<span className="truncate">
{m.name}
<span className="text-stone-3"> {meta.label}</span>
</span>
<span className="tabular-nums text-stone-2">{formatCount(m.activity)}</span>
</div>
<div className="h-1.5 mt-1 rounded-full bg-slate-2" aria-hidden>
<div className="h-full rounded-full" style={{ width: `${Math.max(2, (m.activity / (active[0]?.activity || 1)) * 100)}%`, background: meta.color }} />
</div>
</li>
);
})}
</ul>
{active.length === 0 && <p className="text-sm text-stone-3">Die Modliste kommt mit dem nächsten Serverstart der Statistik-Mod.</p>}
</Section>
</div>
<Section title="FTB Quests">
{quests.defsKnown ? (
<BarList
rows={quests.chapters.map((c) => ({
label: `${c.title ?? c.chapterId}: bestes Team ${c.maxCompleted}/${c.total}${c.teamsFinished ? `, ${c.teamsFinished} fertig` : ''}`,
value: c.total ? (c.maxCompleted / c.total) * 100 : 0,
}))}
format={(v) => `${Math.round(v)} %`}
color="var(--color-unobtainium)"
/>
) : (
<p className="text-sm text-stone-3 mb-4">
Kapitel und Quest-Titel erscheinen, sobald die Quest-Konfiguration des Servers übertragen wurde. Bis dahin zählt jede
abgeschlossene Quest-ID.
</p>
)}
{quests.teams.length > 0 && (
<>
<h3 className="text-sm text-stone-3 mt-5 mb-2">Teams</h3>
<BarList
rows={quests.teams.map((t) => ({
label: t.members.map((m) => m.name ?? '?').join(', ') || t.name || t.teamId,
value: t.completed,
}))}
format={formatCount}
color="var(--color-unobtainium)"
/>
</>
)}
</Section>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
@import "tailwindcss";
/*
* Deepslate: the dark stone of the Minecraft underground as surface family.
* Accents are the three All-The-Mods signature ores, each with one job:
* allthemodium (gold) – emphasis, ranks, links
* vibranium (green) – live / online state
* unobtainium (violet) – quests
* Chart series use the validated reference palette (dark steps), see --series-*.
*/
@theme {
--color-slate-0: #1a1b23;
--color-slate-1: #22242e;
--color-slate-2: #2b2e3a;
--color-slate-3: #3a3e4d;
--color-stone: #e8e6df;
--color-stone-2: #b4b2ba;
--color-stone-3: #8a8893;
--color-gold: #f1b33c;
--color-gold-dim: #8a6a2a;
--color-vibranium: #46d99a;
--color-unobtainium: #b693ff;
--color-danger: #e66767;
--color-series-1: #3987e5;
--color-series-2: #d95926;
--color-series-3: #199e70;
--font-sans: var(--font-bricolage), ui-sans-serif, system-ui, sans-serif;
--font-pixel: var(--font-pixel-face), ui-monospace, monospace;
}
:root {
color-scheme: dark;
}
html {
background: var(--color-slate-0);
}
body {
background: var(--color-slate-0);
color: var(--color-stone);
font-family: var(--font-sans);
font-feature-settings: "tnum" 1;
-webkit-font-smoothing: antialiased;
}
/* Subtle block grid, like looking at a stone wall from close up. */
.block-texture {
background-image:
linear-gradient(to right, rgb(255 255 255 / 0.025) 1px, transparent 1px),
linear-gradient(to bottom, rgb(255 255 255 / 0.025) 1px, transparent 1px);
background-size: 32px 32px;
}
.pixelated {
image-rendering: pixelated;
}
:focus-visible {
outline: 2px solid var(--color-gold);
outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
.ticker-enter {
animation: ticker-in 400ms ease-out;
}
@keyframes ticker-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.pulse-dot {
animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse {
50% {
opacity: 0.35;
}
}
}
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" shape-rendering="crispEdges">
<rect width="16" height="16" fill="#2b2e3a"/>
<rect x="2" y="2" width="5" height="5" fill="#f1b33c"/>
<rect x="9" y="2" width="5" height="5" fill="#46d99a"/>
<rect x="2" y="9" width="5" height="5" fill="#b693ff"/>
<rect x="9" y="9" width="5" height="5" fill="#3a3e4d"/>
</svg>

After

Width:  |  Height:  |  Size: 377 B

+32
View File
@@ -0,0 +1,32 @@
import type { Metadata, Viewport } from 'next';
import { Bricolage_Grotesque, Silkscreen } from 'next/font/google';
import { Footer } from '@/components/Footer';
import { Header } from '@/components/Header';
import './globals.css';
const bricolage = Bricolage_Grotesque({ subsets: ['latin'], variable: '--font-bricolage', display: 'swap' });
const pixel = Silkscreen({ subsets: ['latin'], variable: '--font-pixel-face', weight: ['400', '700'], display: 'swap' });
export const metadata: Metadata = {
title: { default: 'ATM10 Stats', template: '%s – ATM10 Stats' },
description: 'Statistiken, Ranglisten und Spielerprofile unseres All-the-Mods-10-Servers.',
};
export const viewport: Viewport = { themeColor: '#1a1b23' };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de" className={`${bricolage.variable} ${pixel.variable}`}>
<body className="min-h-dvh flex flex-col">
<a href="#inhalt" className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 bg-gold text-slate-0 px-3 py-2 rounded">
Zum Inhalt
</a>
<Header />
<main id="inhalt" className="flex-1 w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
{children}
</main>
<Footer />
</body>
</html>
);
}
+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>
)}
</>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { LeaderboardsDto } from '@atm/shared/api';
import type { Metadata } from 'next';
import { LeaderboardCard } from '@/components/LeaderboardCard';
import { ApiDown, PageTitle } from '@/components/States';
import { apiGet } from '@/lib/api';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = { title: 'Ranglisten' };
export default async function LeaderboardsPage() {
let data: LeaderboardsDto;
try {
data = await apiGet<LeaderboardsDto>('/api/leaderboards');
} catch {
return <ApiDown />;
}
return (
<>
<PageTitle title="Ranglisten">
Top 10 je Kategorie über die gesamte Spielzeit. Ein Klick auf eine Liste zeigt alle Plätze und die letzten 7 oder 30 Tage.
</PageTitle>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{data.boards.map((b) => (
<LeaderboardCard key={b.id} board={b} entries={b.top} />
))}
</div>
</>
);
}
+15
View File
@@ -0,0 +1,15 @@
import Link from 'next/link';
export default function NotFound() {
return (
<div className="py-16 text-center">
<p className="font-pixel text-6xl text-gold">404</p>
<h1 className="text-2xl font-bold mt-4">Hier gibt es nichts abzubauen</h1>
<p className="text-stone-2 mt-2">Diese Seite oder diesen Spieler gibt es nicht. Vielleicht hat sich der Name geändert?</p>
<p className="mt-6 flex justify-center gap-4">
<Link href="/players" className="text-gold underline">Spieler suchen</Link>
<Link href="/" className="text-gold underline">Zur Übersicht</Link>
</p>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import type { LeaderboardsDto, OnlineDto, OverviewDto, ServerDto } from '@atm/shared/api';
import Link from 'next/link';
import { LeaderboardCard } from '@/components/LeaderboardCard';
import { LiveTicker } from '@/components/LiveTicker';
import { ServerStatus } from '@/components/ServerStatus';
import { StatTile } from '@/components/StatTile';
import { ApiDown } from '@/components/States';
import { apiGet } from '@/lib/api';
import { formatCount, formatRelative } from '@/lib/format';
export const dynamic = 'force-dynamic';
export default async function OverviewPage() {
let data: [OverviewDto, OnlineDto, LeaderboardsDto, ServerDto];
try {
data = await Promise.all([
apiGet<OverviewDto>('/api/overview'),
apiGet<OnlineDto>('/api/online'),
apiGet<LeaderboardsDto>('/api/leaderboards'),
apiGet<ServerDto>('/api/server?range=24h'),
]);
} catch {
return <ApiDown />;
}
const [overview, online, boards, server] = data;
const now = Date.now();
return (
<div className="space-y-10">
<div className="grid lg:grid-cols-[1fr_20rem] gap-4">
<ServerStatus online={online} server={server} now={now} />
<LiveTicker />
</div>
<section aria-labelledby="totals-heading">
<h2 id="totals-heading" className="sr-only">Gesamtwerte</h2>
<dl className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<StatTile label="Spielzeit gesamt" value={formatCount(Math.round(overview.totalPlayHours))} unit="Stunden" />
<StatTile label="Spieler" value={formatCount(overview.totalPlayers)} />
<StatTile label="Tode" value={formatCount(overview.totalDeaths)} />
<StatTile label="Besiegte Mobs" value={formatCount(overview.totalMobKills)} />
<StatTile label="Quests erledigt" value={formatCount(overview.questsCompleted)} />
</dl>
<p className="text-xs text-stone-3 mt-2">Zuletzt aktualisiert {formatRelative(overview.lastUpdate, now)}</p>
</section>
<section aria-labelledby="boards-heading">
<div className="flex items-baseline justify-between mb-4">
<h2 id="boards-heading" className="text-2xl font-bold">Bestenlisten</h2>
<Link href="/leaderboards" className="text-sm text-gold hover:underline">
Alle Ranglisten mit Zeiträumen
</Link>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{boards.boards.map((b) => (
<LeaderboardCard key={b.id} board={b} entries={b.top.slice(0, 5)} />
))}
</div>
</section>
</div>
);
}
+247
View File
@@ -0,0 +1,247 @@
import type { LeaderboardsDto, PlayerProfileDto } from '@atm/shared/api';
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { ActivityHeatmap } from '@/components/ActivityHeatmap';
import { BarList } from '@/components/BarList';
import { HistoryChart } from '@/components/HistoryChart';
import { ItemsByMod } from '@/components/ItemsByMod';
import { PlayerHead } from '@/components/PlayerHead';
import { Section } from '@/components/Section';
import { StatTile } from '@/components/StatTile';
import { ApiDown } from '@/components/States';
import { apiFind, apiGet } from '@/lib/api';
import { formatCount, formatDate, formatDateTime, formatKm, formatRelative, formatUnit, prettyId } from '@/lib/format';
export const dynamic = 'force-dynamic';
type Props = { params: Promise<{ name: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { name } = await params;
return { title: decodeURIComponent(name) };
}
const MOVEMENT_LABELS: Record<string, string> = {
'minecraft:walk_one_cm': 'Gehen',
'minecraft:sprint_one_cm': 'Sprinten',
'minecraft:crouch_one_cm': 'Schleichen',
'minecraft:swim_one_cm': 'Schwimmen',
'minecraft:walk_on_water_one_cm': 'Auf Wasser',
'minecraft:walk_under_water_one_cm': 'Unter Wasser',
'minecraft:fly_one_cm': 'Fliegen',
'minecraft:aviate_one_cm': 'Elytra',
'minecraft:climb_one_cm': 'Klettern',
'minecraft:fall_one_cm': 'Fallen',
'minecraft:boat_one_cm': 'Boot',
'minecraft:minecart_one_cm': 'Lore',
'minecraft:horse_one_cm': 'Pferd',
'minecraft:pig_one_cm': 'Schwein',
'minecraft:strider_one_cm': 'Schreiter',
};
export default async function PlayerPage({ params }: Props) {
const { name } = await params;
let p: PlayerProfileDto | null;
let boards: LeaderboardsDto;
try {
[p, boards] = await Promise.all([
apiFind<PlayerProfileDto>(`/api/players/${encodeURIComponent(decodeURIComponent(name))}`),
apiGet<LeaderboardsDto>('/api/leaderboards'),
]);
} catch {
return <ApiDown />;
}
if (!p) notFound();
const now = Date.now();
const g = p.general;
const boardMeta = new Map(boards.boards.map((b) => [b.id, b]));
const ranks = Object.entries(p.ranks).sort((a, b) => a[1].rank - b[1].rank);
return (
<div className="space-y-6">
<header className="flex items-center gap-5">
<PlayerHead uuid={p.uuid} name={p.name} size={80} />
<div className="min-w-0">
<h1 className="text-3xl sm:text-4xl font-bold truncate">{p.name ?? 'Unbekannter Spieler'}</h1>
<p className="text-stone-2 mt-1">
{p.online ? (
<span className="text-vibranium">gerade online</span>
) : (
<>zuletzt online {formatRelative(p.lastSeen, now)}</>
)}
<span className="text-stone-3">, dabei seit {formatDate(p.firstSeen)}</span>
</p>
</div>
</header>
<dl className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<StatTile label="Spielzeit" value={formatCount(Math.round(p.playHours))} unit="Stunden" />
<StatTile label="Sitzungen" value={formatCount(p.sessions)} />
<StatTile label="Tode" value={formatCount(g['minecraft:deaths'] ?? 0)} />
<StatTile label="Besiegte Mobs" value={formatCount(g['minecraft:mob_kills'] ?? 0)} />
<StatTile label="Advancements" value={formatCount(p.advancements.length)} />
<StatTile label="Quests" value={formatCount(p.quests.completed)} hint={p.quests.total ? `von ${formatCount(p.quests.total)}` : undefined} />
</dl>
{ranks.length > 0 && (
<Section title="Platzierungen">
<ul className="flex flex-wrap gap-2">
{ranks.map(([id, r]) => {
const b = boardMeta.get(id);
if (!b) return null;
return (
<li key={id}>
<Link href={`/leaderboards/${id}`} className="flex items-baseline gap-2 rounded-md bg-slate-2 hover:bg-slate-3 px-3 py-1.5 text-sm">
<span className={`font-semibold tabular-nums ${r.rank <= 3 ? 'text-gold' : 'text-stone-2'}`}>#{r.rank}</span>
<span>{b.title}</span>
<span className="text-stone-3 tabular-nums">{formatUnit(r.value, b.unit)}</span>
</Link>
</li>
);
})}
</ul>
</Section>
)}
<Section title="Verlauf">
<HistoryChart uuid={p.uuid} />
</Section>
<Section title="Wann gespielt wird">
<ActivityHeatmap grid={p.activityHeatmap} />
</Section>
<div className="grid lg:grid-cols-2 gap-6">
<Section title="Fortbewegung">
<BarList
rows={Object.entries(p.movement)
.map(([k, v]) => ({ label: MOVEMENT_LABELS[k] ?? prettyId(k), value: v / 100_000 }))
.filter((r) => r.value > 0.005)
.sort((a, b) => b.value - a.value)}
format={formatKm}
empty="Noch keine Strecke erfasst."
/>
</Section>
<Section title="Kämpfe">
<div className="space-y-5">
<div>
<h3 className="text-sm text-stone-3 mb-2">Meistbesiegte Gegner</h3>
<BarList rows={p.killsByType.slice(0, 8).map((k) => ({ label: prettyId(k.entity), value: k.count }))} format={formatCount} />
</div>
<div>
<h3 className="text-sm text-stone-3 mb-2">Gestorben durch</h3>
<BarList
rows={(p.deathsByCause.length
? p.deathsByCause.map((d) => ({ label: prettyId(d.cause), value: d.count }))
: p.killedBy.map((k) => ({ label: prettyId(k.entity), value: k.count }))
).slice(0, 8)}
format={formatCount}
color="var(--color-danger)"
empty="Noch nie gestorben."
/>
</div>
{p.bossKills.length > 0 && (
<div>
<h3 className="text-sm text-stone-3 mb-2">Besiegte Bosse</h3>
<ul className="text-sm space-y-1">
{p.bossKills.slice(0, 10).map((b, i) => (
<li key={`${b.entity}-${b.at}-${i}`} className="flex justify-between gap-3">
<span>{prettyId(b.entity)}</span>
<span className="text-stone-3">{formatDateTime(b.at)}</span>
</li>
))}
</ul>
</div>
)}
</div>
</Section>
</div>
<Section title="Items nach Mod">
<ItemsByMod uuid={p.uuid} />
</Section>
<div className="grid lg:grid-cols-2 gap-6">
<Section title="FTB Quests">
<p className="text-sm text-stone-2 mb-3">
{formatCount(p.quests.completed)} erledigt
{p.quests.total ? ` von ${formatCount(p.quests.total)}` : ''}
{p.quests.teamId !== p.uuid && p.quests.teamName ? `, gemeinsam im Team ${p.quests.teamName}` : ''}
</p>
{p.quests.recent.length > 0 && (
<>
<h3 className="text-sm text-stone-3 mb-2">Zuletzt erledigt</h3>
<ul className="text-sm space-y-1 mb-5">
{p.quests.recent.map((q) => (
<li key={q.questId} className="flex justify-between gap-3">
<span className="truncate">
{q.title}
{q.chapterTitle && <span className="text-stone-3"> in {q.chapterTitle}</span>}
</span>
<span className="text-stone-3 shrink-0">{formatDate(q.at)}</span>
</li>
))}
</ul>
</>
)}
{p.quests.chapters.length === 0 ? (
<p className="text-sm text-stone-3">Kapitelübersicht erscheint, sobald die Quest-Konfiguration des Servers übertragen wurde.</p>
) : (
<>
<h3 className="text-sm text-stone-3 mb-2">Kapitel</h3>
{(() => {
const rows = p.quests.chapters
.filter((c) => c.completed > 0)
.sort((a, b) => b.completed / b.total - a.completed / a.total)
.map((c) => ({ label: `${c.title ?? c.chapterId} (${c.completed}/${c.total})`, value: c.total ? (c.completed / c.total) * 100 : 0 }));
const bar = (r: typeof rows) => (
<BarList rows={r} format={(v) => `${Math.round(v)} %`} color="var(--color-unobtainium)" empty="Noch kein Kapitel begonnen." />
);
return (
<>
{bar(rows.slice(0, 10))}
{rows.length > 10 && (
<details className="mt-3">
<summary className="cursor-pointer text-sm text-gold">Weitere {rows.length - 10} begonnene Kapitel</summary>
<div className="mt-3">{bar(rows.slice(10))}</div>
</details>
)}
</>
);
})()}
</>
)}
</Section>
<Section title="Advancements">
{p.advancements.length === 0 ? (
<p className="text-sm text-stone-3">Noch keine Advancements erfasst.</p>
) : (
<ul className="text-sm space-y-1 max-h-80 overflow-y-auto pr-2">
{p.advancements.map((a) => (
<li key={a.id} className="flex justify-between gap-3">
<span className="truncate">{a.title ?? prettyId(a.id)}</span>
<span className="text-stone-3 shrink-0">{formatDate(a.at)}</span>
</li>
))}
</ul>
)}
{p.dimensions.length > 0 && (
<>
<h3 className="text-sm text-stone-3 mt-5 mb-2">Besuchte Dimensionen</h3>
<ul className="flex flex-wrap gap-2 text-sm">
{p.dimensions.map((d) => (
<li key={d.dimension} className="rounded bg-slate-2 px-2 py-0.5" title={`zuerst ${formatDate(d.firstAt)}`}>
{prettyId(d.dimension)}
</li>
))}
</ul>
</>
)}
</Section>
</div>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import type { PlayerListDto } from '@atm/shared/api';
import type { Metadata } from 'next';
import Link from 'next/link';
import { PlayerHead } from '@/components/PlayerHead';
import { ApiDown, EmptyState, PageTitle } from '@/components/States';
import { apiGet } from '@/lib/api';
import { formatHours, formatRelative } from '@/lib/format';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = { title: 'Spieler' };
const PAGE_SIZE = 50;
export default async function PlayersPage({ searchParams }: { searchParams: Promise<{ search?: string; page?: string }> }) {
const { search = '', page = '0' } = await searchParams;
const pageNo = Math.max(0, Number.parseInt(page, 10) || 0);
let data: PlayerListDto;
try {
data = await apiGet<PlayerListDto>(`/api/players?search=${encodeURIComponent(search)}&page=${pageNo}`);
} catch {
return <ApiDown />;
}
const now = Date.now();
const pages = Math.ceil(data.total / PAGE_SIZE);
const link = (p: number) => `/players?${new URLSearchParams({ ...(search ? { search } : {}), page: String(p) })}`;
return (
<>
<PageTitle title="Spieler">Alle Spieler, die seit Beginn der Aufzeichnung auf dem Server waren.</PageTitle>
<form role="search" className="flex gap-2 mb-6 max-w-md">
<label htmlFor="search" className="sr-only">Spielername</label>
<input
id="search"
name="search"
defaultValue={search}
placeholder="Spielername suchen"
maxLength={16}
className="flex-1 rounded-md bg-slate-1 border border-slate-3 px-3 py-2 placeholder:text-stone-3"
/>
<button type="submit" className="rounded-md bg-gold text-slate-0 font-semibold px-4">Suchen</button>
</form>
{data.players.length === 0 ? (
<EmptyState title={search ? `Kein Spieler namens „${search}“ gefunden` : 'Noch keine Spieler erfasst'}>
{search ? 'Prüfe die Schreibweise oder suche nach einem Teil des Namens.' : 'Spieler erscheinen nach ihrem ersten Login.'}
</EmptyState>
) : (
<ul className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{data.players.map((p) => (
<li key={p.uuid}>
<Link href={`/players/${p.name ?? p.uuid}`} className="flex items-center gap-3 rounded-lg bg-slate-1 border border-slate-2 hover:border-slate-3 p-3">
<PlayerHead uuid={p.uuid} name={p.name} size={40} />
<span className="min-w-0">
<span className="block font-semibold truncate">{p.name}</span>
<span className="block text-sm text-stone-3">
{p.online ? <span className="text-vibranium">online</span> : `zuletzt ${formatRelative(p.lastSeen, now)}`}, {formatHours(p.playHours)}
</span>
</span>
</Link>
</li>
))}
</ul>
)}
{pages > 1 && (
<nav aria-label="Seiten" className="flex gap-4 mt-6 text-sm">
{pageNo > 0 && <Link href={link(pageNo - 1)} className="text-gold">Vorherige Seite</Link>}
<span className="text-stone-3">Seite {pageNo + 1} von {pages}</span>
{pageNo + 1 < pages && <Link href={link(pageNo + 1)} className="text-gold">Nächste Seite</Link>}
</nav>
)}
</>
);
}
+90
View File
@@ -0,0 +1,90 @@
import type { ModsDto, ServerDto } from '@atm/shared/api';
import type { Metadata } from 'next';
import Link from 'next/link';
import { HealthCharts } from '@/components/HealthCharts';
import { ModTable } from '@/components/ModTable';
import { Section } from '@/components/Section';
import { ApiDown, EmptyState, PageTitle } from '@/components/States';
import { apiGet } from '@/lib/api';
import { formatDateTime } from '@/lib/format';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = { title: 'Server' };
const RANGES = [
{ id: '24h', label: '24 Stunden' },
{ id: '7d', label: '7 Tage' },
{ id: '30d', label: '30 Tage' },
] as const;
export default async function ServerPage({ searchParams }: { searchParams: Promise<{ range?: string }> }) {
const raw = (await searchParams).range;
const range = RANGES.some((r) => r.id === raw) ? raw! : '24h';
let server: ServerDto;
let mods: ModsDto;
try {
[server, mods] = await Promise.all([apiGet<ServerDto>(`/api/server?range=${range}`), apiGet<ModsDto>('/api/mods')]);
} catch {
return <ApiDown />;
}
const info = server.info;
return (
<div className="space-y-6">
<PageTitle title="Server">Versionen, installierte Mods und die Leistung des Servers, gemessen von der Statistik-Mod.</PageTitle>
<Section title="Versionen">
{info ? (
<dl className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
<div>
<dt className="text-stone-3">Minecraft</dt>
<dd className="font-pixel text-xl">{info.mcVersion}</dd>
</div>
<div>
<dt className="text-stone-3">NeoForge</dt>
<dd className="font-pixel text-xl">{info.neoforgeVersion}</dd>
</div>
<div>
<dt className="text-stone-3">Java</dt>
<dd className="font-pixel text-xl">{info.javaVersion}</dd>
</div>
<div>
<dt className="text-stone-3">Mods geladen</dt>
<dd className="font-pixel text-xl">{info.modCount}</dd>
</div>
<p className="col-span-full text-stone-3">Gemeldet beim Serverstart am {formatDateTime(info.startedAt)}.</p>
</dl>
) : (
<p className="text-sm text-stone-3">Die Statistik-Mod hat noch keinen Serverstart gemeldet.</p>
)}
</Section>
<section aria-labelledby="perf-heading">
<div className="flex flex-wrap items-baseline justify-between gap-3 mb-4">
<h2 id="perf-heading" className="text-2xl font-bold">Leistung</h2>
<nav aria-label="Zeitraum" className="inline-flex rounded-md bg-slate-1 border border-slate-2 p-0.5 text-sm">
{RANGES.map((r) => (
<Link
key={r.id}
href={r.id === '24h' ? '/server' : `/server?range=${r.id}`}
aria-current={r.id === range ? 'page' : undefined}
className={`px-3 py-1 rounded ${r.id === range ? 'bg-slate-3 text-stone' : 'text-stone-2 hover:text-stone'}`}
>
{r.label}
</Link>
))}
</nav>
</div>
{server.health.length < 2 ? (
<EmptyState title="Noch keine Leistungsdaten">Die Mod meldet TPS und Tickdauer jede Minute, sobald sie verbunden ist.</EmptyState>
) : (
<HealthCharts data={server.health} />
)}
</section>
<Section title="Installierte Mods">
{mods.mods.length ? <ModTable mods={mods.mods} /> : <p className="text-sm text-stone-3">Die Modliste wird beim Serverstart von der Statistik-Mod gemeldet.</p>}
</Section>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import type { SourcesDto } from '@atm/shared/api';
import type { Metadata } from 'next';
import { Section } from '@/components/Section';
import { ApiDown, PageTitle } from '@/components/States';
import { apiGet } from '@/lib/api';
import { formatRelative } from '@/lib/format';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = { title: 'Datenquellen' };
const SOURCES: { what: string; from: string; when: string }[] = [
{ what: 'Online-Status, Sitzungen, Spielzeit-Heatmap', from: 'Statistik-Mod (Beitritt/Verlassen)', when: 'sofort' },
{ what: 'Zähler: Spielzeit, Tode, Mobs, Strecken, Items', from: 'Live-Zähler der Mod; world/stats per SFTP', when: 'alle 5 Min. bzw. beim Speichern' },
{ what: 'Todesursachen, Boss- und Spieler-Kills', from: 'Statistik-Mod', when: 'sofort' },
{ what: 'Advancements', from: 'Statistik-Mod; world/advancements per SFTP; Server-Log', when: 'sofort bzw. alle 15 Min.' },
{ what: 'FTB-Quest-Fortschritt', from: 'world/ftbquests (Mod oder SFTP)', when: 'alle 2 bzw. 15 Min.' },
{ what: 'TPS, Tickdauer, Speicher, geladene Chunks', from: 'eigene Messung der Statistik-Mod', when: 'jede Minute' },
{ what: 'Versionen und Modliste', from: 'Statistik-Mod beim Serverstart', when: 'bei jedem Start' },
];
export default async function SourcesPage() {
let data: SourcesDto;
try {
data = await apiGet<SourcesDto>('/api/sources');
} catch {
return <ApiDown />;
}
const now = Date.now();
return (
<div className="space-y-6">
<PageTitle title="Datenquellen">Woher jede Zahl stammt, wie aktuell sie ist und was dieses Portal bewusst nicht anzeigt.</PageTitle>
<div className="grid sm:grid-cols-2 gap-4">
<Section title="Statistik-Mod">
<p className="flex items-center gap-2">
<span className={`size-2.5 rounded-full ${data.push.reachable ? 'bg-vibranium' : 'bg-danger'}`} aria-hidden />
{data.push.reachable ? 'Verbunden' : 'Keine aktuellen Daten'}
</p>
<p className="text-sm text-stone-3 mt-2">
Letzte Meldung {formatRelative(data.push.lastEventAt, now)}, letzter Serverstart {formatRelative(data.push.lastServerStart, now)}.
</p>
</Section>
<Section title="SFTP-Abgleich">
<p className="flex items-center gap-2">
<span className={`size-2.5 rounded-full ${data.sftp.enabled && !data.sftp.lastError ? 'bg-vibranium' : 'bg-stone-3'}`} aria-hidden />
{data.sftp.enabled ? (data.sftp.lastError ? 'Fehler beim letzten Abgleich' : 'Aktiv') : 'Nicht eingerichtet'}
</p>
<p className="text-sm text-stone-3 mt-2">
{data.sftp.enabled ? `Letzter Abgleich ${formatRelative(data.sftp.lastSyncAt, now)}.` : 'Ohne SFTP liefert nur die Mod Daten.'}
</p>
</Section>
</div>
<Section title="Woher die Zahlen kommen">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-stone-3">
<tr>
<th className="py-2 pr-4 font-normal">Kennzahl</th>
<th className="py-2 pr-4 font-normal">Quelle</th>
<th className="py-2 font-normal">Aktualität</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-2">
{SOURCES.map((s) => (
<tr key={s.what}>
<td className="py-2 pr-4">{s.what}</td>
<td className="py-2 pr-4 text-stone-2">{s.from}</td>
<td className="py-2 text-stone-2">{s.when}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
<Section title="Was nicht erfasst wird">
<ul className="list-disc pl-5 space-y-1.5 text-sm text-stone-2 max-w-3xl">
{data.limitations.map((l) => (
<li key={l}>{l}</li>
))}
</ul>
</Section>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
const DAYS = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
const SHORT = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
// Sequential blue (reference ramp), dark → light = little → much on the dark surface; zero recedes to the surface.
const STEPS = ['#184f95', '#1c5cab', '#256abf', '#2a78d6', '#3987e5', '#5598e7', '#86b6ef'];
function color(v: number, max: number): string {
if (v <= 0) return 'var(--color-slate-2)';
const i = Math.min(STEPS.length - 1, Math.floor((v / max) * STEPS.length));
return STEPS[i]!;
}
/** Minutes online per weekday × hour (server time zone), last 90 days. */
export function ActivityHeatmap({ grid }: { grid: number[][] }) {
const max = Math.max(0, ...grid.flat());
if (max === 0) return <p className="text-sm text-stone-3">In den letzten 90 Tagen keine Sitzungen erfasst.</p>;
return (
<div className="overflow-x-auto">
<div className="grid gap-[2px] min-w-[520px]" style={{ gridTemplateColumns: '2rem repeat(24, minmax(0, 1fr))' }}>
<span />
{Array.from({ length: 24 }, (_, h) => (
<span key={h} className="text-[10px] text-stone-3 text-center">
{h % 3 === 0 ? h : ''}
</span>
))}
{grid.map((row, d) => (
<div key={d} className="contents">
<span className="text-xs text-stone-3 self-center">{SHORT[d]}</span>
{row.map((v, h) => (
<span
key={h}
role="img"
aria-label={`${DAYS[d]}, ${h} Uhr: ${v} Minuten`}
title={`${DAYS[d]}, ${h}–${h + 1} Uhr: ${v} Min.`}
className="aspect-square rounded-[3px]"
style={{ background: color(v, max) }}
/>
))}
</div>
))}
</div>
<p className="text-xs text-stone-3 mt-2">Heller = mehr Minuten online. Zeiten in deutscher Zeit.</p>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
/** Ranked horizontal bars for one measure (single colour; the heading names the series). */
export function BarList({
rows,
format = String,
empty = 'Keine Daten',
color = 'var(--color-series-1)',
}: {
rows: { label: string; value: number }[];
format?: (v: number) => string;
empty?: string;
color?: string;
}) {
if (rows.length === 0) return <p className="text-sm text-stone-3">{empty}</p>;
const max = Math.max(...rows.map((r) => r.value), 1);
return (
<ul className="space-y-2">
{rows.map((r) => (
<li key={r.label} className="text-sm">
<div className="flex justify-between gap-3">
<span className="truncate">{r.label}</span>
<span className="tabular-nums text-stone-2">{format(r.value)}</span>
</div>
<div className="h-1.5 mt-1 rounded-full bg-slate-2" aria-hidden>
<div className="h-full rounded-full" style={{ width: `${Math.max(2, (r.value / max) * 100)}%`, background: color }} />
</div>
</li>
))}
</ul>
);
}
+15
View File
@@ -0,0 +1,15 @@
import Link from 'next/link';
export function Footer() {
return (
<footer className="border-t border-slate-2 mt-12">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 text-sm text-stone-3 flex flex-col sm:flex-row gap-3 sm:justify-between">
<p>Inoffizielles Statistikportal. Kein offizielles Minecraft-Produkt, nicht von Mojang oder Microsoft genehmigt.</p>
<p className="flex gap-4">
<Link href="/sources" className="hover:text-stone">Datenquellen</Link>
<Link href="/admin" className="hover:text-stone">Verwaltung</Link>
</p>
</div>
</footer>
);
}
+15
View File
@@ -0,0 +1,15 @@
import Link from 'next/link';
import { NavLinks } from './NavLinks';
export function Header() {
return (
<header className="border-b border-slate-2 bg-slate-0/90 backdrop-blur sticky top-0 z-40">
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-14 flex items-center gap-6">
<Link href="/" className="font-pixel text-xl text-gold shrink-0" aria-label="ATM10 Stats, zur Übersicht">
ATM10<span className="text-stone"> Stats</span>
</Link>
<NavLinks />
</div>
</header>
);
}
+72
View File
@@ -0,0 +1,72 @@
'use client';
import type { HealthPointDto } from '@atm/shared/api';
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { formatDateTime } from '@/lib/format';
const AXIS = { tick: { fill: '#8a8893', fontSize: 12 }, stroke: '#3a3e4d' };
const TOOLTIP = {
contentStyle: { background: '#22242e', border: '1px solid #3a3e4d', borderRadius: 6 },
labelStyle: { color: '#b4b2ba' },
itemStyle: { color: '#e8e6df' },
cursor: { stroke: '#8a8893', strokeWidth: 1 },
};
const time = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin' });
const day = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', timeZone: 'Europe/Berlin' });
function Chart({
title,
data,
series,
unit,
domain,
}: {
title: string;
data: HealthPointDto[];
series: { key: keyof HealthPointDto; name: string; color: string }[];
unit: string;
domain?: [number, number | 'auto'];
}) {
const span = data.length ? data.at(-1)!.t - data[0]!.t : 0;
const fmt = (t: number) => (span > 2 * 86_400_000 ? day.format(t) : time.format(t));
return (
<figure className="rounded-lg bg-slate-1 border border-slate-2 p-4">
<figcaption className="font-semibold mb-3">{title}</figcaption>
<div className="h-48" role="img" aria-label={title}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} stroke="#2b2e3a" />
<XAxis dataKey="t" type="number" scale="time" domain={['dataMin', 'dataMax']} tickFormatter={fmt} minTickGap={40} {...AXIS} />
<YAxis width={48} domain={domain ?? [0, 'auto']} tickFormatter={(v: number) => `${v}`} {...AXIS} />
<Tooltip {...TOOLTIP} labelFormatter={(t) => formatDateTime(Number(t))} formatter={(v, n) => [`${v} ${unit}`, n]} />
{series.length > 1 && <Legend wrapperStyle={{ fontSize: 12, color: '#b4b2ba' }} />}
{series.map((s) => (
<Line key={s.key} type="monotone" dataKey={s.key} name={s.name} stroke={s.color} strokeWidth={2} dot={false} isAnimationActive={false} />
))}
</LineChart>
</ResponsiveContainer>
</div>
</figure>
);
}
/** Four separate charts, one scale each (no dual axes). */
export function HealthCharts({ data }: { data: HealthPointDto[] }) {
return (
<div className="grid lg:grid-cols-2 gap-4">
<Chart title="TPS (Ticks pro Sekunde, Ziel 20)" data={data} unit="TPS" domain={[0, 20]} series={[{ key: 'tps', name: 'TPS', color: '#3987e5' }]} />
<Chart
title="Tickdauer"
data={data}
unit="ms"
series={[
{ key: 'msptAvg', name: 'Durchschnitt', color: '#3987e5' },
{ key: 'msptP95', name: '95. Perzentil', color: '#d95926' },
]}
/>
<Chart title="Spieler online" data={data} unit="Spieler" series={[{ key: 'players', name: 'Spieler', color: '#3987e5' }]} />
<Chart title="Arbeitsspeicher belegt" data={data} unit="MB" series={[{ key: 'heapUsedMb', name: 'Belegt', color: '#3987e5' }]} />
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
'use client';
import type { HistoryDto } from '@atm/shared/api';
import { useEffect, useState } from 'react';
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { formatDate, formatDateTime, formatUnit } from '@/lib/format';
const METRICS = [
{ id: 'playtime', label: 'Spielzeit' },
{ id: 'mined', label: 'Abgebaut' },
{ id: 'mobkills', label: 'Mobs' },
{ id: 'deaths', label: 'Tode' },
{ id: 'distance', label: 'Strecke' },
{ id: 'tech', label: 'Tech' },
{ id: 'magic', label: 'Magie' },
{ id: 'quests', label: 'Quests' },
];
const RANGES = [
{ id: '7d', label: '7 Tage' },
{ id: '30d', label: '30 Tage' },
{ id: 'all', label: 'Gesamt' },
];
function Toggle({ options, value, onChange, label }: { options: { id: string; label: string }[]; value: string; onChange: (v: string) => void; label: string }) {
return (
<div role="group" aria-label={label} className="flex flex-wrap gap-1 text-sm">
{options.map((o) => (
<button
key={o.id}
type="button"
aria-pressed={o.id === value}
onClick={() => onChange(o.id)}
className={`px-2.5 py-1 rounded ${o.id === value ? 'bg-slate-3 text-stone' : 'text-stone-2 hover:text-stone'}`}
>
{o.label}
</button>
))}
</div>
);
}
/** One metric over time from the stored snapshots (single series: the metric picker names it, no legend). */
export function HistoryChart({ uuid }: { uuid: string }) {
const [metric, setMetric] = useState('playtime');
const [range, setRange] = useState('30d');
const [data, setData] = useState<HistoryDto | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let alive = true;
setFailed(false);
fetch(`/api/players/${uuid}/history?metric=${metric}&range=${range}`)
.then((r) => (r.ok ? (r.json() as Promise<HistoryDto>) : Promise.reject(new Error(String(r.status)))))
.then((d) => alive && setData(d))
.catch(() => alive && setFailed(true));
return () => {
alive = false;
};
}, [uuid, metric, range]);
const points = data?.metric === metric ? data.points : [];
const unit = data?.unit ?? 'count';
return (
<div>
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
<Toggle label="Kennzahl" options={METRICS} value={metric} onChange={setMetric} />
<Toggle label="Zeitraum" options={RANGES} value={range} onChange={setRange} />
</div>
{failed ? (
<p className="text-sm text-stone-3">Verlauf konnte nicht geladen werden.</p>
) : points.length < 2 ? (
<p className="text-sm text-stone-3 h-56 flex items-center">
Für diesen Zeitraum gibt es noch zu wenige Messpunkte. Verläufe entstehen aus stündlichen und täglichen Snapshots.
</p>
) : (
<div className="h-56" role="img" aria-label={`Verlauf ${METRICS.find((m) => m.id === metric)?.label}`}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={points} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="hist-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3987e5" stopOpacity={0.35} />
<stop offset="100%" stopColor="#3987e5" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} stroke="#2b2e3a" />
<XAxis
dataKey="t"
type="number"
scale="time"
domain={['dataMin', 'dataMax']}
tickFormatter={(t: number) => formatDate(t)}
tick={{ fill: '#8a8893', fontSize: 12 }}
stroke="#3a3e4d"
minTickGap={40}
/>
<YAxis
width={64}
tickFormatter={(v: number) => formatUnit(v, unit)}
tick={{ fill: '#8a8893', fontSize: 12 }}
stroke="#3a3e4d"
domain={['auto', 'auto']}
/>
<Tooltip
cursor={{ stroke: '#8a8893', strokeWidth: 1 }}
contentStyle={{ background: '#22242e', border: '1px solid #3a3e4d', borderRadius: 6 }}
labelStyle={{ color: '#b4b2ba' }}
itemStyle={{ color: '#e8e6df' }}
labelFormatter={(t) => formatDateTime(Number(t))}
formatter={(v) => [formatUnit(Number(v), unit), METRICS.find((m) => m.id === metric)?.label]}
/>
<Area type="monotone" dataKey="v" stroke="#3987e5" strokeWidth={2} fill="url(#hist-fill)" dot={false} activeDot={{ r: 4 }} />
</AreaChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
'use client';
import { ITEM_CATEGORIES, type ItemCategory, type ItemsDto } from '@atm/shared/api';
import { useEffect, useState } from 'react';
import { formatCount, prettyId } from '@/lib/format';
const LABELS: Record<ItemCategory, string> = {
mined: 'Abgebaut',
crafted: 'Hergestellt',
used: 'Benutzt',
picked_up: 'Aufgesammelt',
};
/** Item counters grouped by the mod that adds the item; mods expand to their top items. */
export function ItemsByMod({ uuid }: { uuid: string }) {
const [category, setCategory] = useState<ItemCategory>('mined');
const [data, setData] = useState<ItemsDto | null>(null);
useEffect(() => {
let alive = true;
fetch(`/api/players/${uuid}/items?category=${category}`)
.then((r) => (r.ok ? (r.json() as Promise<ItemsDto>) : null))
.then((d) => alive && setData(d))
.catch(() => alive && setData(null));
return () => {
alive = false;
};
}, [uuid, category]);
const mods = data?.category === category ? data.mods : [];
const max = Math.max(...mods.map((m) => m.total), 1);
return (
<div>
<div role="tablist" aria-label="Art" className="flex flex-wrap gap-1 text-sm mb-4">
{ITEM_CATEGORIES.map((c) => (
<button
key={c}
role="tab"
type="button"
aria-selected={c === category}
onClick={() => setCategory(c)}
className={`px-2.5 py-1 rounded ${c === category ? 'bg-slate-3 text-stone' : 'text-stone-2 hover:text-stone'}`}
>
{LABELS[c]}
</button>
))}
</div>
{mods.length === 0 ? (
<p className="text-sm text-stone-3">Noch keine Werte in dieser Kategorie.</p>
) : (
<ul role="tabpanel" className="space-y-1">
{mods.slice(0, 15).map((m) => (
<li key={m.namespace}>
<details className="group rounded-md hover:bg-slate-2/50">
<summary className="cursor-pointer list-none px-2 py-1.5 text-sm">
<span className="flex justify-between gap-3">
<span className="truncate">
<span className="inline-block w-3 text-stone-3 group-open:rotate-90 transition-transform" aria-hidden>›</span> {m.namespace}
</span>
<span className="tabular-nums text-stone-2">{formatCount(m.total)}</span>
</span>
<span className="block h-1.5 mt-1 rounded-full bg-slate-2" aria-hidden>
<span className="block h-full rounded-full bg-series-1" style={{ width: `${Math.max(2, (m.total / max) * 100)}%` }} />
</span>
</summary>
<ul className="px-6 pb-2 text-sm text-stone-2 space-y-0.5">
{m.items.map((i) => (
<li key={i.key} className="flex justify-between gap-3">
<span className="truncate">{prettyId(i.key)}</span>
<span className="tabular-nums">{formatCount(i.value)}</span>
</li>
))}
</ul>
</details>
</li>
))}
</ul>
)}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { BoardMetaDto, LeaderboardEntryDto } from '@atm/shared/api';
import Link from 'next/link';
import { formatUnit } from '@/lib/format';
import { PlayerHead } from './PlayerHead';
const RANK_TONE = ['text-gold', 'text-stone', 'text-[#c98b4f]'];
export function LeaderboardCard({ board, entries }: { board: BoardMetaDto; entries: LeaderboardEntryDto[] }) {
return (
<section className="rounded-lg bg-slate-1 border border-slate-2 p-4 flex flex-col" aria-labelledby={`board-${board.id}`}>
<h3 id={`board-${board.id}`} className="font-semibold">
<Link href={`/leaderboards/${board.id}`} className="hover:text-gold">
{board.title}
</Link>
</h3>
<p className="text-sm text-stone-3 mb-3">{board.description}</p>
{entries.length === 0 ? (
<p className="text-sm text-stone-3 mt-auto">Noch keine Daten</p>
) : (
<ol className="space-y-1.5">
{entries.map((e) => (
<li key={e.uuid} className="flex items-center gap-2 text-sm">
<span className={`w-5 text-right font-semibold tabular-nums ${RANK_TONE[e.rank - 1] ?? 'text-stone-3'}`}>{e.rank}</span>
<PlayerHead uuid={e.uuid} name={e.name} size={20} />
<Link href={`/players/${e.name ?? e.uuid}`} className="truncate hover:text-gold">
{e.name ?? 'Unbekannt'}
</Link>
<span className="ml-auto tabular-nums text-stone-2">{formatUnit(e.value, board.unit)}</span>
</li>
))}
</ol>
)}
</section>
);
}
+77
View File
@@ -0,0 +1,77 @@
'use client';
import type { LiveEvent } from '@atm/shared/api';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useRef, useState } from 'react';
import { prettyId } from '@/lib/format';
import { useLive } from '@/lib/useLive';
const REFRESH_THROTTLE_MS = 15_000;
const MAX_ITEMS = 8;
type Item = { id: number; at: number; text: React.ReactNode; tone: string };
function describe(e: LiveEvent): Omit<Item, 'id' | 'at'> | null {
const d = e.data as Record<string, string | boolean | null>;
const who = d.name ? (
<Link href={`/players/${d.name}`} className="font-semibold hover:text-gold">
{d.name}
</Link>
) : (
'Jemand'
);
switch (e.kind) {
case 'death':
return {
tone: 'bg-danger',
text: (
<>
{who} ist gestorben{d.killerType ? ` (${prettyId(String(d.killerType))})` : ` (${prettyId(String(d.cause))})`}
</>
),
};
case 'advancement':
return { tone: 'bg-gold', text: <>{who} hat „{d.title}“ erreicht</> };
case 'online':
return { tone: d.online ? 'bg-vibranium' : 'bg-stone-3', text: <>{who} {d.online ? 'ist beigetreten' : 'hat den Server verlassen'}</> };
case 'quest':
return { tone: 'bg-unobtainium', text: <>Neuer Quest-Fortschritt</> };
default:
return null;
}
}
/** Live events from the server; also refreshes the server-rendered numbers on the page (throttled). */
export function LiveTicker() {
const router = useRouter();
const [items, setItems] = useState<Item[]>([]);
const lastRefresh = useRef(0);
useLive((e) => {
const view = describe(e);
if (view) setItems((prev) => [{ id: e.id, at: Date.now(), ...view }, ...prev].slice(0, MAX_ITEMS));
if (Date.now() - lastRefresh.current > REFRESH_THROTTLE_MS) {
lastRefresh.current = Date.now();
router.refresh();
}
});
return (
<section aria-labelledby="ticker-heading" className="rounded-lg bg-slate-1 border border-slate-2 p-4">
<h2 id="ticker-heading" className="font-semibold mb-3">Live</h2>
{items.length === 0 ? (
<p className="text-sm text-stone-3">Neue Tode, Advancements und Beitritte erscheinen hier, sobald sie passieren.</p>
) : (
<ul className="space-y-2 text-sm" aria-live="polite">
{items.map((i) => (
<li key={i.id} className="ticker-enter flex items-start gap-2">
<span className={`mt-1.5 size-2 rounded-full shrink-0 ${i.tone}`} aria-hidden />
<span>{i.text}</span>
</li>
))}
</ul>
)}
</section>
);
}
+57
View File
@@ -0,0 +1,57 @@
'use client';
import type { ModsDto } from '@atm/shared/api';
import { useMemo, useState } from 'react';
import { formatCount } from '@/lib/format';
const CATEGORY_LABEL: Record<string, string> = { tech: 'Technik', magic: 'Magie', farming: 'Farming' };
export function ModTable({ mods }: { mods: ModsDto['mods'] }) {
const [q, setQ] = useState('');
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase();
return needle ? mods.filter((m) => m.name.toLowerCase().includes(needle) || m.id.includes(needle)) : mods;
}, [mods, q]);
return (
<div>
<label className="block mb-3 max-w-sm">
<span className="sr-only">Mods filtern</span>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={`${mods.length} Mods durchsuchen`}
className="w-full rounded-md bg-slate-1 border border-slate-3 px-3 py-2 placeholder:text-stone-3"
/>
</label>
<div className="overflow-x-auto rounded-lg border border-slate-2">
<table className="w-full text-sm">
<thead className="bg-slate-1 text-stone-3 text-left">
<tr>
<th className="px-3 py-2 font-normal">Mod</th>
<th className="px-3 py-2 font-normal">Version</th>
<th className="px-3 py-2 font-normal">Bereich</th>
<th className="px-3 py-2 font-normal text-right">Aktivität</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-2">
{filtered.slice(0, 200).map((m) => (
<tr key={m.id}>
<td className="px-3 py-1.5">
{m.name} <span className="text-stone-3">({m.id})</span>
</td>
<td className="px-3 py-1.5 text-stone-2 break-all">{m.version}</td>
<td className="px-3 py-1.5 text-stone-2">{m.category ? CATEGORY_LABEL[m.category] : ''}</td>
<td className="px-3 py-1.5 text-right tabular-nums">{m.activity ? formatCount(m.activity) : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
{filtered.length > 200 && <p className="text-xs text-stone-3 mt-2">Die ersten 200 von {filtered.length} Treffern. Suche eingrenzen, um weitere zu sehen.</p>}
<p className="text-xs text-stone-3 mt-2">
Aktivität = abgebaute, hergestellte, benutzte und aufgesammelte Items dieser Mod über alle Spieler.
</p>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const LINKS = [
{ href: '/', label: 'Übersicht' },
{ href: '/leaderboards', label: 'Ranglisten' },
{ href: '/players', label: 'Spieler' },
{ href: '/atm10', label: 'ATM10' },
{ href: '/server', label: 'Server' },
];
export function NavLinks() {
const path = usePathname();
return (
<nav aria-label="Hauptnavigation" className="overflow-x-auto -mx-2">
<ul className="flex gap-1 text-sm whitespace-nowrap">
{LINKS.map((l) => {
const active = l.href === '/' ? path === '/' : path.startsWith(l.href);
return (
<li key={l.href}>
<Link
href={l.href}
aria-current={active ? 'page' : undefined}
className={`block px-2 py-1.5 rounded ${active ? 'text-gold' : 'text-stone-2 hover:text-stone'}`}
>
{l.label}
</Link>
</li>
);
})}
</ul>
</nav>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { Period } from '@atm/shared/api';
import Link from 'next/link';
const PERIODS: { id: Period; label: string }[] = [
{ id: 'all', label: 'Gesamt' },
{ id: '30d', label: '30 Tage' },
{ id: '7d', label: '7 Tage' },
];
export function PeriodTabs({ base, active }: { base: string; active: Period }) {
return (
<nav aria-label="Zeitraum" className="inline-flex rounded-md bg-slate-1 border border-slate-2 p-0.5 text-sm">
{PERIODS.map((p) => (
<Link
key={p.id}
href={p.id === 'all' ? base : `${base}?period=${p.id}`}
aria-current={p.id === active ? 'page' : undefined}
className={`px-3 py-1 rounded ${p.id === active ? 'bg-slate-3 text-stone' : 'text-stone-2 hover:text-stone'}`}
>
{p.label}
</Link>
))}
</nav>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { avatarUrl } from '@/lib/api';
/** Player head served by our own API (cached server-side; visitors never contact third parties). */
export function PlayerHead({ uuid, name, size = 32 }: { uuid: string; name: string | null; size?: number }) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatarUrl(uuid)}
alt={name ? `Kopf von ${name}` : ''}
width={size}
height={size}
loading="lazy"
className="pixelated rounded-sm bg-slate-2 shrink-0"
/>
);
}
+11
View File
@@ -0,0 +1,11 @@
export function Section({ title, children, className = '' }: { title: string; children: React.ReactNode; className?: string }) {
const id = `s-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
return (
<section aria-labelledby={id} className={`rounded-lg bg-slate-1 border border-slate-2 p-4 sm:p-5 ${className}`}>
<h2 id={id} className="font-semibold mb-4">
{title}
</h2>
{children}
</section>
);
}
+66
View File
@@ -0,0 +1,66 @@
import type { OnlineDto, ServerDto } from '@atm/shared/api';
import Link from 'next/link';
import { formatRelative, prettyId } from '@/lib/format';
import { PlayerHead } from './PlayerHead';
/** The page's one loud element: who is in the world right now, in big pixel type, with their heads. */
export function ServerStatus({ online, server, now }: { online: OnlineDto; server: ServerDto | null; now: number }) {
const last = server?.health.at(-1);
const fresh = last !== undefined && now - last.t < 5 * 60_000;
const count = online.players.length;
return (
<section aria-labelledby="status-heading" className="rounded-xl bg-slate-1 border border-slate-2 block-texture p-5 sm:p-8">
<h1 id="status-heading" className="sr-only">Serverstatus</h1>
<div className="flex flex-col lg:flex-row gap-8 lg:items-end">
<div className="flex-1 min-w-0">
<p className="flex items-center gap-2 text-stone-2">
<span className={`inline-block size-2.5 rounded-full ${count > 0 ? 'bg-vibranium pulse-dot' : 'bg-stone-3'}`} aria-hidden />
{count > 0 ? 'Gerade in der Welt' : 'Gerade ist niemand online'}
</p>
<p className="font-pixel text-7xl sm:text-8xl leading-none mt-3 text-stone tabular-nums">
{count}
<span className="font-sans text-2xl sm:text-3xl text-stone-3 ml-4 align-middle">Spieler online</span>
</p>
{count > 0 && (
<ul className="flex flex-wrap gap-3 mt-6">
{online.players.map((p) => (
<li key={p.uuid}>
<Link
href={`/players/${p.name ?? p.uuid}`}
className="flex items-center gap-2 rounded-md bg-slate-2 hover:bg-slate-3 pl-1 pr-3 py-1"
>
<PlayerHead uuid={p.uuid} name={p.name} size={28} />
<span>
<span className="block text-sm leading-tight">{p.name}</span>
<span className="block text-xs text-stone-3 leading-tight">
{p.dimension ? prettyId(p.dimension) : 'Oberwelt'} · seit {formatRelative(p.since, now).replace('vor ', '')}
</span>
</span>
</Link>
</li>
))}
</ul>
)}
</div>
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm shrink-0">
<div>
<dt className="text-stone-3">TPS</dt>
<dd className={`font-pixel text-2xl ${fresh && last!.tps < 18 ? 'text-danger' : 'text-stone'}`}>
{fresh ? last!.tps.toFixed(1) : '–'}
</dd>
</div>
<div>
<dt className="text-stone-3">Tickzeit</dt>
<dd className="font-pixel text-2xl text-stone">{fresh ? Math.round(last!.msptAvg) : "–"}{fresh && <span className="font-sans text-sm text-stone-3 ml-1">ms</span>}</dd>
</div>
<div className="col-span-2">
<dt className="text-stone-3">Version</dt>
<dd className="text-stone-2">
{server?.info ? `Minecraft ${server.info.mcVersion}, NeoForge ${server.info.neoforgeVersion}` : 'noch nicht gemeldet'}
</dd>
</div>
</dl>
</div>
</section>
);
}
+13
View File
@@ -0,0 +1,13 @@
/** Number in pixel type, unit and label in text type (the pixel face has no lowercase). */
export function StatTile({ label, value, unit, hint }: { label: string; value: string; unit?: string; hint?: string }) {
return (
<div className="rounded-lg bg-slate-1 border border-slate-2 px-4 py-3">
<dt className="text-sm text-stone-3">{label}</dt>
<dd className="mt-1 tabular-nums">
<span className="font-pixel text-2xl sm:text-3xl text-stone">{value}</span>
{unit && <span className="text-stone-3 ml-1.5">{unit}</span>}
</dd>
{hint && <dd className="text-xs text-stone-3 mt-1">{hint}</dd>}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import Link from 'next/link';
export function EmptyState({ title, children }: { title: string; children?: React.ReactNode }) {
return (
<div className="rounded-lg border border-dashed border-slate-3 p-6 text-center">
<p className="font-semibold">{title}</p>
{children && <div className="text-sm text-stone-3 mt-1">{children}</div>}
</div>
);
}
export function ApiDown() {
return (
<EmptyState title="Die Statistik-Schnittstelle antwortet gerade nicht">
Die Seite lädt die Daten beim nächsten Aufruf neu. Hält der Fehler an, prüfe den Status unter{' '}
<Link href="/sources" className="text-gold underline">Datenquellen</Link>.
</EmptyState>
);
}
export function PageTitle({ title, children }: { title: string; children?: React.ReactNode }) {
return (
<div className="mb-8">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight">{title}</h1>
{children && <p className="text-stone-2 mt-2 max-w-2xl">{children}</p>}
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
// Server components call the API directly (container network); the browser uses same-origin /api.
const base = typeof window === 'undefined' ? (process.env.API_INTERNAL_URL ?? 'http://127.0.0.1:3001') : '';
export class ApiError extends Error {
constructor(
readonly status: number,
path: string,
) {
super(`API ${status} for ${path}`);
}
}
export async function apiGet<T>(path: string): Promise<T> {
let res: Response;
try {
res = await fetch(`${base}${path}`, { cache: 'no-store' });
} catch {
throw new ApiError(503, path);
}
if (!res.ok) throw new ApiError(res.status, path);
return (await res.json()) as T;
}
/** Like apiGet, but resolves to null for 404 (unknown player, board …). */
export async function apiFind<T>(path: string): Promise<T | null> {
try {
return await apiGet<T>(path);
} catch (e) {
if (e instanceof ApiError && e.status === 404) return null;
throw e;
}
}
export const avatarUrl = (uuid: string) => `/api/avatar/${uuid}`;
+57
View File
@@ -0,0 +1,57 @@
import type { Unit } from '@atm/shared/api';
const count = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 });
const oneDecimal = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
export const formatCount = (n: number) => count.format(n);
export const formatHours = (h: number) => `${oneDecimal.format(h)} h`;
export const formatKm = (km: number) => `${oneDecimal.format(km)} km`;
export function formatUnit(value: number, unit: Unit): string {
switch (unit) {
case 'hours':
return formatHours(value);
case 'km':
return formatKm(value);
default:
return formatCount(value);
}
}
export function formatRelative(ms: number | null, now = Date.now()): string {
if (ms === null) return 'nie';
const diff = Math.max(0, now - ms);
const min = Math.floor(diff / 60_000);
if (min < 1) return 'gerade eben';
if (min < 60) return `vor ${min} Min.`;
const h = Math.floor(min / 60);
if (h < 24) return `vor ${h} Std.`;
const d = Math.floor(h / 24);
return d === 1 ? 'vor 1 Tag' : `vor ${d} Tagen`;
}
export function formatDuration(ms: number): string {
const min = Math.round(ms / 60_000);
if (min < 60) return `${min} Min.`;
const h = Math.floor(min / 60);
const rest = min % 60;
return rest ? `${h} Std. ${rest} Min.` : `${h} Std.`;
}
const dateTime = new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short', timeZone: 'Europe/Berlin' });
const date = new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeZone: 'Europe/Berlin' });
export const formatDateTime = (ms: number) => dateTime.format(ms);
export const formatDate = (ms: number) => date.format(ms);
/** "minecraft:wither_skeleton" → "Wither Skeleton" (registry ids have no translation server-side). */
export function prettyId(id: string): string {
const path = id.includes(':') ? id.slice(id.indexOf(':') + 1) : id;
const last = path.split('/').pop() ?? path;
return last
.split('_')
.filter(Boolean)
.map((w) => w[0]!.toUpperCase() + w.slice(1))
.join(' ');
}
export const namespaceOf = (id: string) => (id.includes(':') ? id.slice(0, id.indexOf(':')) : 'minecraft');
+31
View File
@@ -0,0 +1,31 @@
'use client';
import type { LiveEvent, LiveKind } from '@atm/shared/api';
import { useEffect, useRef } from 'react';
const KINDS: LiveKind[] = ['online', 'death', 'advancement', 'quest', 'counters'];
/** Subscribes to /api/live (SSE). EventSource reconnects on its own and resumes via Last-Event-ID. */
export function useLive(onEvent: (e: LiveEvent) => void): void {
const handler = useRef(onEvent);
handler.current = onEvent;
useEffect(() => {
const source = new EventSource('/api/live');
const listeners = KINDS.map((kind) => {
const fn = (msg: MessageEvent<string>) => {
try {
handler.current({ id: Number(msg.lastEventId), kind, data: JSON.parse(msg.data) });
} catch {
/* ignore malformed message */
}
};
source.addEventListener(kind, fn as EventListener);
return [kind, fn] as const;
});
return () => {
for (const [kind, fn] of listeners) source.removeEventListener(kind, fn as EventListener);
source.close();
};
}, []);
}
+22
View File
@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { ActivityHeatmap } from '@/components/ActivityHeatmap';
describe('ActivityHeatmap', () => {
it('renders 168 cells with aria labels', () => {
const grid = Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0));
grid[0]![20] = 45;
grid[6]![9] = 5;
render(<ActivityHeatmap grid={grid} />);
const cells = screen.getAllByRole('img');
expect(cells).toHaveLength(168);
expect(screen.getByRole('img', { name: 'Montag, 20 Uhr: 45 Minuten' })).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Sonntag, 9 Uhr: 5 Minuten' })).toBeInTheDocument();
});
it('shows an empty note when there is no activity', () => {
const grid = Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0));
render(<ActivityHeatmap grid={grid} />);
expect(screen.getByText('In den letzten 90 Tagen keine Sitzungen erfasst.')).toBeInTheDocument();
});
});
+29
View File
@@ -0,0 +1,29 @@
import { render, screen, within } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { LeaderboardCard } from '@/components/LeaderboardCard';
const board = { id: 'deaths', title: 'Friedhof', description: 'Häufigste Tode', unit: 'count' as const };
const entries = Array.from({ length: 10 }, (_, i) => ({
rank: i + 1,
uuid: `00000000-0000-4000-8000-00000000000${i}`,
name: `Spieler${i}`,
value: 100 - i,
}));
describe('LeaderboardCard', () => {
it('renders 10 rows with names, values and a link to the full board', () => {
render(<LeaderboardCard board={board} entries={entries} />);
const rows = screen.getAllByRole('listitem');
expect(rows).toHaveLength(10);
expect(within(rows[0]!).getByText('Spieler0')).toBeInTheDocument();
expect(within(rows[0]!).getByText('100')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Friedhof/ })).toHaveAttribute('href', '/leaderboards/deaths');
expect(rows[0]!.querySelector('a')).toHaveAttribute('href', '/players/Spieler0');
});
it('renders an empty state', () => {
render(<LeaderboardCard board={board} entries={[]} />);
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
expect(screen.getByText('Noch keine Daten')).toBeInTheDocument();
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { formatCount, formatDuration, formatHours, formatKm, formatRelative, formatUnit, prettyId } from '@/lib/format';
describe('format', () => {
it('formats counts with German grouping', () => {
expect(formatCount(1234)).toBe('1.234');
expect(formatCount(1234567)).toBe('1.234.567');
expect(formatCount(0)).toBe('0');
});
it('formats hours with one decimal and unit', () => {
expect(formatHours(1175736 / 72000)).toBe('16,3 h');
expect(formatHours(1234.5)).toBe('1.234,5 h');
});
it('formats kilometres', () => {
expect(formatKm(2)).toBe('2,0 km');
expect(formatKm(32.337)).toBe('32,3 km');
});
it('formats by board unit', () => {
expect(formatUnit(16.33, 'hours')).toBe('16,3 h');
expect(formatUnit(5, 'count')).toBe('5');
expect(formatUnit(1200, 'blocks')).toBe('1.200');
expect(formatUnit(2, 'km')).toBe('2,0 km');
});
it('formats relative times', () => {
const now = Date.UTC(2026, 9, 13, 12, 0, 0);
expect(formatRelative(now - 20_000, now)).toBe('gerade eben');
expect(formatRelative(now - 5 * 60_000, now)).toBe('vor 5 Min.');
expect(formatRelative(now - 3 * 3600_000, now)).toBe('vor 3 Std.');
expect(formatRelative(now - 2 * 86_400_000, now)).toBe('vor 2 Tagen');
expect(formatRelative(null, now)).toBe('nie');
});
it('formats durations', () => {
expect(formatDuration(45 * 60_000)).toBe('45 Min.');
expect(formatDuration(125 * 60_000)).toBe('2 Std. 5 Min.');
});
it('turns resource ids into readable names', () => {
expect(prettyId('minecraft:wither_skeleton')).toBe('Wither Skeleton');
expect(prettyId('minecraft:play_time')).toBe('Play Time');
expect(prettyId('allthemodium:the_other')).toBe('The Other');
});
});
+5
View File
@@ -0,0 +1,5 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(cleanup);
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+13
View File
@@ -0,0 +1,13 @@
import { fileURLToPath } from 'node:url';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
test: {
environment: 'jsdom',
include: ['test/**/*.test.{ts,tsx}'],
setupFiles: ['test/setup.ts'],
},
});