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:
1 parent
56d2078e6a
commit
e66f89f49e
214 files changed
+27077
No files matched your search
@@ -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/
|
||||
@@ -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"]
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'] } },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 <name>“ 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
@@ -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');
|
||||
@@ -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();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user