initial commit of the paytime session frontend
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPlayersPresentDuringMap } from '@/lib/queries';
|
||||
import { getSteamSummariesBatch, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
|
||||
import { steam2ToSteam64 } from '@/lib/steam';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const mapIdParam = req.nextUrl.searchParams.get('mapId');
|
||||
const mapId = Number(mapIdParam);
|
||||
if (!mapIdParam || !Number.isInteger(mapId)) {
|
||||
return NextResponse.json({ error: 'mapId query param is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const players = await getPlayersPresentDuringMap(mapId);
|
||||
|
||||
const steam64ByPlayer = new Map<string, string>();
|
||||
for (const p of players) {
|
||||
const id64 = steam2ToSteam64(p.steamid);
|
||||
if (id64) steam64ByPlayer.set(p.steamid, id64);
|
||||
}
|
||||
|
||||
const avatars = await getSteamSummariesBatch([...steam64ByPlayer.values()]);
|
||||
|
||||
const result = players.map((p) => {
|
||||
const id64 = steam64ByPlayer.get(p.steamid);
|
||||
const avatarUrl = (id64 && avatars.get(id64)?.avatarUrl) || DEFAULT_AVATAR_URL;
|
||||
return { steamid: p.steamid, name: p.current_name, avatarUrl };
|
||||
});
|
||||
|
||||
return NextResponse.json({ players: result });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getMapPopulationSeries } from '@/lib/queries';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const limitParam = req.nextUrl.searchParams.get('limit');
|
||||
const limit = Math.min(Math.max(Number(limitParam) || 40, 1), 200);
|
||||
const points = await getMapPopulationSeries(limit);
|
||||
return NextResponse.json({ points });
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { searchMapNames } from '@/lib/queries';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const q = req.nextUrl.searchParams.get('q')?.trim() ?? '';
|
||||
const maps = await searchMapNames(q);
|
||||
return NextResponse.json({ maps });
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
interface PlayerRow {
|
||||
steamid: string;
|
||||
current_name: string;
|
||||
current_country: string | null;
|
||||
matched_name: string | null;
|
||||
total_minutes: number | null;
|
||||
}
|
||||
|
||||
const TOTAL_MINUTES_SUBQUERY = `
|
||||
(SELECT COALESCE(s.session_end_playtime_minutes, s.session_start_playtime_minutes)
|
||||
FROM playtime_display_sessions s
|
||||
WHERE s.steamid = p.steamid
|
||||
ORDER BY s.session_id DESC
|
||||
LIMIT 1) AS total_minutes
|
||||
`;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const q = req.nextUrl.searchParams.get('q')?.trim() ?? '';
|
||||
const sort = req.nextUrl.searchParams.get('sort') ?? 'recent';
|
||||
|
||||
const orderBy =
|
||||
sort === 'playtime'
|
||||
? 'total_minutes IS NULL, total_minutes DESC'
|
||||
: sort === 'name'
|
||||
? 'p.current_name'
|
||||
: 'p.last_seen DESC';
|
||||
|
||||
// No search term: just list players, ordered per the chosen sort.
|
||||
if (!q) {
|
||||
const rows = await query<PlayerRow>(
|
||||
`SELECT p.steamid, p.current_name, p.current_country, NULL AS matched_name,
|
||||
${TOTAL_MINUTES_SUBQUERY}
|
||||
FROM playtime_display_players p
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT 50`,
|
||||
);
|
||||
return NextResponse.json({ players: rows });
|
||||
}
|
||||
|
||||
// Search matches steamid directly, or current/previous names.
|
||||
const like = `%${q}%`;
|
||||
const rows = await query<PlayerRow>(
|
||||
`SELECT p.steamid, p.current_name, p.current_country, h.player_name AS matched_name,
|
||||
${TOTAL_MINUTES_SUBQUERY}
|
||||
FROM playtime_display_players p
|
||||
LEFT JOIN playtime_display_name_history h
|
||||
ON h.steamid = p.steamid AND h.player_name LIKE ?
|
||||
WHERE p.steamid LIKE ?
|
||||
OR p.current_name LIKE ?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM playtime_display_name_history h2
|
||||
WHERE h2.steamid = p.steamid AND h2.player_name LIKE ?
|
||||
)
|
||||
GROUP BY p.steamid
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT 50`,
|
||||
[like, like, like, like],
|
||||
);
|
||||
|
||||
return NextResponse.json({ players: rows });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getCurrentPlayers } from '@/lib/queries';
|
||||
import { getSteamSummariesBatch, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
|
||||
import { steam2ToSteam64 } from '@/lib/steam';
|
||||
|
||||
export async function GET() {
|
||||
const players = await getCurrentPlayers(3);
|
||||
|
||||
const steam64ByPlayer = new Map<string, string>();
|
||||
for (const p of players) {
|
||||
const id64 = steam2ToSteam64(p.steamid);
|
||||
if (id64) steam64ByPlayer.set(p.steamid, id64);
|
||||
}
|
||||
|
||||
const avatars = await getSteamSummariesBatch([...steam64ByPlayer.values()]);
|
||||
|
||||
const result = players.map((p) => {
|
||||
const id64 = steam64ByPlayer.get(p.steamid);
|
||||
const avatarUrl = (id64 && avatars.get(id64)?.avatarUrl) || DEFAULT_AVATAR_URL;
|
||||
return { steamid: p.steamid, name: p.current_name, avatarUrl };
|
||||
});
|
||||
|
||||
return NextResponse.json({ players: result, count: result.length });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPlayersAtTime } from '@/lib/queries';
|
||||
import { getSteamSummariesBatch, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
|
||||
import { steam2ToSteam64 } from '@/lib/steam';
|
||||
import { formatDbDateTime } from '@/lib/timezone';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const at = req.nextUrl.searchParams.get('at');
|
||||
if (!at) {
|
||||
return NextResponse.json({ error: 'at query param is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const atDate = new Date(at);
|
||||
if (Number.isNaN(atDate.getTime())) {
|
||||
return NextResponse.json({ error: 'invalid at timestamp' }, { status: 400 });
|
||||
}
|
||||
|
||||
const players = await getPlayersAtTime(formatDbDateTime(atDate));
|
||||
|
||||
const steam64ByPlayer = new Map<string, string>();
|
||||
for (const p of players) {
|
||||
const id64 = steam2ToSteam64(p.steamid);
|
||||
if (id64) steam64ByPlayer.set(p.steamid, id64);
|
||||
}
|
||||
|
||||
const avatars = await getSteamSummariesBatch([...steam64ByPlayer.values()]);
|
||||
|
||||
const result = players.map((p) => {
|
||||
const id64 = steam64ByPlayer.get(p.steamid);
|
||||
const avatarUrl = (id64 && avatars.get(id64)?.avatarUrl) || DEFAULT_AVATAR_URL;
|
||||
return { steamid: p.steamid, name: p.current_name, avatarUrl };
|
||||
});
|
||||
|
||||
return NextResponse.json({ players: result });
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
import { parseDbDate } from '@/lib/dates';
|
||||
import { formatDbDateTime } from '@/lib/timezone';
|
||||
|
||||
interface SessionRow {
|
||||
session_start_dt: string;
|
||||
session_end_dt: string | null;
|
||||
}
|
||||
|
||||
interface MapRow {
|
||||
map_id: number;
|
||||
map_name: string;
|
||||
map_start_dt: string;
|
||||
}
|
||||
|
||||
// Picks a bucket size that keeps the number of points in a sane range
|
||||
// (roughly 60-300 buckets) regardless of how wide the requested range is.
|
||||
function pickBucketMinutes(rangeMinutes: number): number {
|
||||
const target = 150;
|
||||
const raw = Math.ceil(rangeMinutes / target);
|
||||
const steps = [1, 5, 15, 30, 60, 120, 240, 360, 720, 1440]; // minutes
|
||||
return steps.find((s) => s >= raw) ?? steps[steps.length - 1];
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const startParam = searchParams.get('start');
|
||||
const endParam = searchParams.get('end');
|
||||
|
||||
if (!startParam || !endParam) {
|
||||
return NextResponse.json({ error: 'start and end query params are required (ISO datetimes)' }, { status: 400 });
|
||||
}
|
||||
|
||||
const rangeStart = new Date(startParam);
|
||||
const rangeEnd = new Date(endParam);
|
||||
|
||||
if (Number.isNaN(rangeStart.getTime()) || Number.isNaN(rangeEnd.getTime()) || rangeStart >= rangeEnd) {
|
||||
return NextResponse.json({ error: 'invalid or inverted start/end range' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Convert to MySQL-native "YYYY-MM-DD HH:MM:SS" strings (in the DB's own
|
||||
// timezone) rather than passing raw ISO-Z strings as query parameters —
|
||||
// MySQL DATETIME columns don't reliably parse the T/Z/milliseconds ISO
|
||||
// format, which was silently dropping/mismatching rows in this fetch.
|
||||
const formattedEnd = formatDbDateTime(rangeEnd);
|
||||
const formattedStart = formatDbDateTime(rangeStart);
|
||||
// Generous safety margin so the map fetch below doesn't scan the entire
|
||||
// history table as it grows over months/years — 24h is far longer than
|
||||
// any realistic single map duration, so this can never miss the map that
|
||||
// was actually active at rangeStart.
|
||||
const formattedLookback = formatDbDateTime(new Date(rangeStart.getTime() - 24 * 60 * 60 * 1000));
|
||||
|
||||
const [sessionRows, mapRows] = await Promise.all([
|
||||
query<SessionRow>(
|
||||
`SELECT session_start_dt, session_end_dt
|
||||
FROM playtime_display_sessions
|
||||
WHERE session_start_dt < ?
|
||||
AND (session_end_dt IS NULL OR session_end_dt > ?)`,
|
||||
[formattedEnd, formattedStart],
|
||||
),
|
||||
// map_end_dt was dropped from the schema (redundant with, and
|
||||
// occasionally out of sync with, the next row's map_start_dt). Filter
|
||||
// tickrate-restart artifacts (<2min) using the gap to the next map;
|
||||
// the currently-running map (no next row yet) always passes.
|
||||
query<MapRow>(
|
||||
`SELECT m.map_id, m.map_name, m.map_start_dt
|
||||
FROM playtime_display_map_history m
|
||||
WHERE m.map_start_dt < ?
|
||||
AND m.map_start_dt > ?
|
||||
AND (
|
||||
(SELECT MIN(m2.map_start_dt) FROM playtime_display_map_history m2 WHERE m2.map_start_dt > m.map_start_dt) IS NULL
|
||||
OR TIMESTAMPDIFF(
|
||||
MINUTE, m.map_start_dt,
|
||||
(SELECT MIN(m2.map_start_dt) FROM playtime_display_map_history m2 WHERE m2.map_start_dt > m.map_start_dt)
|
||||
) >= 2
|
||||
)
|
||||
ORDER BY m.map_start_dt`,
|
||||
[formattedEnd, formattedLookback],
|
||||
),
|
||||
]);
|
||||
|
||||
const sessions = sessionRows.map((r) => ({
|
||||
start: parseDbDate(r.session_start_dt),
|
||||
end: r.session_end_dt ? parseDbDate(r.session_end_dt) : new Date(), // still-open session heartbeat, see plugin notes
|
||||
}));
|
||||
|
||||
const maps = mapRows.map((r) => ({
|
||||
mapId: r.map_id,
|
||||
mapName: r.map_name,
|
||||
start: parseDbDate(r.map_start_dt),
|
||||
}));
|
||||
|
||||
// `maps` is sorted ascending by start_dt (from the ORDER BY in the query
|
||||
// above). Deliberately does NOT check each row's own end time — a map
|
||||
// whose map_end_dt never got closed (e.g. OnMapEnd not firing for a
|
||||
// specific transition, due to a crash/restart/admin changelevel) would
|
||||
// otherwise look "still running" forever and permanently shadow every
|
||||
// real map that came after it. Instead: whichever map most recently
|
||||
// started by time t is what was playing then, regardless of whether its
|
||||
// own end timestamp was ever reliably recorded.
|
||||
function mapNameAt(t: Date): string | null {
|
||||
let result: string | null = null;
|
||||
for (const m of maps) {
|
||||
if (m.start <= t) {
|
||||
result = m.mapName;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const rangeMinutes = (rangeEnd.getTime() - rangeStart.getTime()) / 60000;
|
||||
const bucketMinutes = pickBucketMinutes(rangeMinutes);
|
||||
|
||||
const points: { t: string; players: number; mapName: string | null }[] = [];
|
||||
for (let t = rangeStart.getTime(); t < rangeEnd.getTime(); t += bucketMinutes * 60000) {
|
||||
const bucketStart = new Date(t);
|
||||
const bucketEnd = new Date(t + bucketMinutes * 60000);
|
||||
const count = sessions.filter((s) => s.start < bucketEnd && s.end > bucketStart).length;
|
||||
points.push({ t: bucketStart.toISOString(), players: count, mapName: mapNameAt(bucketStart) });
|
||||
}
|
||||
|
||||
// Map-change boundaries within the visible range, for drawing vertical
|
||||
// separator lines on the chart — only the ones whose start actually
|
||||
// falls inside [rangeStart, rangeEnd).
|
||||
const mapBoundaries = maps
|
||||
.filter((m) => m.start >= rangeStart && m.start < rangeEnd)
|
||||
.map((m) => ({ mapId: m.mapId, mapName: m.mapName, t: m.start.toISOString() }));
|
||||
|
||||
return NextResponse.json({ bucketMinutes, points, mapBoundaries });
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getPlayersByCountry } from '@/lib/queries';
|
||||
import { countryCodeToFlag, countryCodeToName, formatMinutes } from '@/lib/steam';
|
||||
|
||||
export default async function CountryPlayersPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ code: string }>;
|
||||
searchParams: Promise<{ sort?: string }>;
|
||||
}) {
|
||||
const { code: rawCode } = await params;
|
||||
const code = decodeURIComponent(rawCode).toUpperCase();
|
||||
|
||||
const { sort: rawSort } = await searchParams;
|
||||
const sort = rawSort === 'playtime' ? 'playtime' : 'recent';
|
||||
|
||||
const players = await getPlayersByCountry(code, sort);
|
||||
if (players.length === 0) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href="/countries" className="text-sm text-ink-muted hover:text-accent transition-colors">
|
||||
← All countries
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl text-ink mb-1 flex items-center gap-2">
|
||||
<span aria-hidden>{countryCodeToFlag(code)}</span>
|
||||
{countryCodeToName(code)}
|
||||
</h1>
|
||||
<p className="text-sm text-ink-muted">
|
||||
{players.length} player{players.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 text-sm">
|
||||
<Link
|
||||
href={`/countries/${encodeURIComponent(code)}?sort=recent`}
|
||||
className={`px-3 py-1.5 rounded border transition-colors ${
|
||||
sort === 'recent'
|
||||
? 'border-accent/50 text-accent bg-accent-dim/20'
|
||||
: 'border-base-border text-ink-muted hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
Most recently played
|
||||
</Link>
|
||||
<Link
|
||||
href={`/countries/${encodeURIComponent(code)}?sort=playtime`}
|
||||
className={`px-3 py-1.5 rounded border transition-colors ${
|
||||
sort === 'playtime'
|
||||
? 'border-accent/50 text-accent bg-accent-dim/20'
|
||||
: 'border-base-border text-ink-muted hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
Highest playtime
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel divide-y divide-base-border">
|
||||
{players.map((p) => (
|
||||
<Link
|
||||
key={p.steamid}
|
||||
href={`/players/${encodeURIComponent(p.steamid)}`}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
|
||||
>
|
||||
<div className="text-ink text-sm">{p.current_name}</div>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className="text-ink-faint mono">{p.steamid}</span>
|
||||
<span className="text-ink-muted mono w-16 text-right">
|
||||
{formatMinutes(p.total_minutes)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Link from 'next/link';
|
||||
import { getCountriesSummary } from '@/lib/queries';
|
||||
import { countryCodeToFlag, countryCodeToName } from '@/lib/steam';
|
||||
|
||||
// This page queries the DB directly with no dynamic route segment, so
|
||||
// without this Next.js tries to statically pre-render it at BUILD time
|
||||
// (requiring a live DB connection then, not just at request time).
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function CountriesPage() {
|
||||
const countries = await getCountriesSummary();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl text-ink mb-1">Countries</h1>
|
||||
<p className="text-sm text-ink-muted">
|
||||
Where the server's players connect from, most players first.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{countries.length === 0 ? (
|
||||
<div className="panel p-6 text-sm text-ink-muted">No countries recorded yet.</div>
|
||||
) : (
|
||||
<div className="panel divide-y divide-base-border">
|
||||
{countries.map((c) => (
|
||||
<Link
|
||||
key={c.current_country}
|
||||
href={`/countries/${encodeURIComponent(c.current_country)}`}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg" aria-hidden>
|
||||
{countryCodeToFlag(c.current_country)}
|
||||
</span>
|
||||
<span className="text-ink text-sm">{countryCodeToName(c.current_country)}</span>
|
||||
<span className="text-ink-faint text-xs mono">{c.current_country}</span>
|
||||
</div>
|
||||
<div className="text-sm text-ink-muted mono">
|
||||
{c.player_count} player{c.player_count === 1 ? '' : 's'}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,33 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-base text-ink font-sans antialiased;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-accent/30 text-ink;
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible {
|
||||
@apply outline-none ring-2 ring-accent ring-offset-2 ring-offset-base;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.panel {
|
||||
@apply bg-base-panel border border-base-border rounded-lg;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
@apply text-xs uppercase tracking-wider text-ink-muted font-mono;
|
||||
}
|
||||
|
||||
.mono {
|
||||
@apply font-mono text-sm;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'unloze — server stats',
|
||||
description: 'Playtime, population and map stats for the unloze server',
|
||||
};
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/', label: 'Overview' },
|
||||
{ href: '/players', label: 'Players' },
|
||||
{ href: '/maps', label: 'Maps' },
|
||||
{ href: '/countries', label: 'Countries' },
|
||||
];
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="border-b border-base-border">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-accent shadow-[0_0_8px_theme(colors.accent.DEFAULT)]" />
|
||||
<span className="font-mono text-sm tracking-widest text-ink uppercase">
|
||||
unloze playtime sessions
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="flex gap-6">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-sm text-ink-muted hover:text-accent transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 max-w-6xl w-full mx-auto px-6 py-8">{children}</main>
|
||||
<footer className="border-t border-base-border py-4">
|
||||
<div className="max-w-6xl mx-auto px-6 text-xs text-ink-faint font-mono">
|
||||
unloze playtime stats
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getMapPeriods } from '@/lib/queries';
|
||||
import { diffMinutes } from '@/lib/dates';
|
||||
import { formatMinutes } from '@/lib/steam';
|
||||
|
||||
export default async function MapPeriodsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ mapname: string }>;
|
||||
}) {
|
||||
const { mapname: rawMapname } = await params;
|
||||
const mapName = decodeURIComponent(rawMapname);
|
||||
|
||||
const periods = await getMapPeriods(mapName);
|
||||
if (periods.length === 0) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href="/maps" className="text-sm text-ink-muted hover:text-accent transition-colors">
|
||||
← Back to maps
|
||||
</Link>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl text-ink mb-1">{mapName}</h1>
|
||||
<p className="text-sm text-ink-muted">
|
||||
Played {periods.length} time{periods.length === 1 ? '' : 's'}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="panel divide-y divide-base-border">
|
||||
{periods.map((p) => (
|
||||
<Link
|
||||
key={p.map_id}
|
||||
href={`/maps/period/${p.map_id}`}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
|
||||
>
|
||||
<div>
|
||||
<div className="text-ink text-sm">
|
||||
{new Date(p.map_start_dt.replace(' ', 'T')).toLocaleString()}
|
||||
</div>
|
||||
{!p.map_end_dt && <div className="text-xs text-accent">currently running</div>}
|
||||
</div>
|
||||
<div className="mono text-ink-faint text-sm">
|
||||
{p.map_end_dt ? formatMinutes(diffMinutes(p.map_start_dt, p.map_end_dt)) : '—'}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import MapSearch from '@/components/MapSearch';
|
||||
|
||||
export default function MapsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl text-ink mb-1">Maps</h1>
|
||||
<p className="text-sm text-ink-muted">Search for a map to see when it was played.</p>
|
||||
</div>
|
||||
<MapSearch />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import {
|
||||
getMapPeriod,
|
||||
getPlayersPresentDuringMap,
|
||||
getMapVotes,
|
||||
getAdjacentMaps,
|
||||
} from '@/lib/queries';
|
||||
import { diffMinutes } from '@/lib/dates';
|
||||
import { formatMinutes, countryCodeToFlag } from '@/lib/steam';
|
||||
|
||||
export default async function MapPeriodDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ mapId: string }>;
|
||||
}) {
|
||||
const { mapId: rawMapId } = await params;
|
||||
const mapId = Number(rawMapId);
|
||||
|
||||
if (!Number.isInteger(mapId)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const period = await getMapPeriod(mapId);
|
||||
if (!period) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [players, votes, adjacent] = await Promise.all([
|
||||
getPlayersPresentDuringMap(mapId),
|
||||
getMapVotes(mapId),
|
||||
getAdjacentMaps(period.map_start_dt),
|
||||
]);
|
||||
|
||||
// Group vote rows by their vote event (a map can theoretically have more
|
||||
// than one vote called during its runtime).
|
||||
const voteEvents = new Map<number, { vote_time: string; result: string | null; choices: typeof votes }>();
|
||||
for (const v of votes) {
|
||||
if (!voteEvents.has(v.vote_id)) {
|
||||
voteEvents.set(v.vote_id, { vote_time: v.vote_time, result: v.result, choices: [] });
|
||||
}
|
||||
voteEvents.get(v.vote_id)!.choices.push(v);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href={`/maps/${encodeURIComponent(period.map_name)}`}
|
||||
className="text-sm text-ink-muted hover:text-accent transition-colors"
|
||||
>
|
||||
← All plays of {period.map_name}
|
||||
</Link>
|
||||
|
||||
{/* Header with prev/next navigation */}
|
||||
<div className="panel p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
{adjacent.prev ? (
|
||||
<Link
|
||||
href={`/maps/period/${adjacent.prev.map_id}`}
|
||||
className="text-sm text-ink-muted hover:text-accent transition-colors"
|
||||
>
|
||||
← {adjacent.prev.map_name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm text-ink-faint">start of history</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl text-ink">{period.map_name}</h1>
|
||||
<div className="text-xs text-ink-faint mt-1">
|
||||
{new Date(period.map_start_dt.replace(' ', 'T')).toLocaleString()}
|
||||
{' — '}
|
||||
{period.map_end_dt
|
||||
? new Date(period.map_end_dt.replace(' ', 'T')).toLocaleTimeString()
|
||||
: 'still running'}
|
||||
{period.map_end_dt && (
|
||||
<span className="ml-2">
|
||||
({formatMinutes(diffMinutes(period.map_start_dt, period.map_end_dt))})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 text-right">
|
||||
{adjacent.next ? (
|
||||
<Link
|
||||
href={`/maps/period/${adjacent.next.map_id}`}
|
||||
className="text-sm text-ink-muted hover:text-accent transition-colors"
|
||||
>
|
||||
{adjacent.next.map_name} →
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm text-ink-faint">end of history</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Players present */}
|
||||
<div className="panel p-6">
|
||||
<div className="stat-label mb-3">
|
||||
Players present ({players.length})
|
||||
</div>
|
||||
{players.length === 0 ? (
|
||||
<div className="text-sm text-ink-muted">No recorded players during this period.</div>
|
||||
) : (
|
||||
<div className="divide-y divide-base-border">
|
||||
{players.map((p, i) => (
|
||||
<Link
|
||||
key={`${p.steamid}-${i}`}
|
||||
href={`/players/${encodeURIComponent(p.steamid)}`}
|
||||
className="flex items-center justify-between py-2 text-sm hover:bg-base transition-colors px-2 -mx-2 rounded"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span aria-hidden>{countryCodeToFlag(p.current_country)}</span>
|
||||
<span className="text-ink">{p.current_name}</span>
|
||||
</div>
|
||||
<div className="text-ink-faint text-xs mono">
|
||||
{new Date(p.session_start_dt.replace(' ', 'T')).toLocaleTimeString()}
|
||||
{' – '}
|
||||
{p.session_end_dt
|
||||
? new Date(p.session_end_dt.replace(' ', 'T')).toLocaleTimeString()
|
||||
: 'now'}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Votes */}
|
||||
<div className="panel p-6">
|
||||
<div className="stat-label mb-3">Map votes</div>
|
||||
{voteEvents.size === 0 ? (
|
||||
<div className="text-sm text-ink-muted">No recorded vote during this period.</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{[...voteEvents.entries()].map(([voteId, event]) => (
|
||||
<div key={voteId}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-xs text-ink-faint mono">
|
||||
{new Date(event.vote_time.replace(' ', 'T')).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-accent">Result: {event.result ?? '—'}</div>
|
||||
</div>
|
||||
<div className="divide-y divide-base-border">
|
||||
{event.choices.map((c, i) => (
|
||||
<Link
|
||||
key={`${c.steamid}-${i}`}
|
||||
href={`/players/${encodeURIComponent(c.steamid)}`}
|
||||
className="flex items-center justify-between py-2 text-sm hover:bg-base transition-colors px-2 -mx-2 rounded"
|
||||
>
|
||||
<span className="text-ink">{c.current_name}</span>
|
||||
<span className="text-ink-muted">
|
||||
voted <span className="text-ink">{c.vote_choice}</span>
|
||||
{c.vote_weight !== 1 && (
|
||||
<span className="text-ink-faint"> (weight {c.vote_weight})</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import PopulationChart from '@/components/PopulationChart';
|
||||
import MapPopulationChart from '@/components/MapPopulationChart';
|
||||
|
||||
export default function OverviewPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl text-ink mb-1">Overview</h1>
|
||||
<p className="text-sm text-ink-muted">Population and activity across the server.</p>
|
||||
</div>
|
||||
<PopulationChart />
|
||||
<MapPopulationChart />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { getPlayer, getNameHistory, getSessions, getTotalPlaytimeMinutes } from '@/lib/queries';
|
||||
import { steam2ToSteam64, steamProfileUrl, formatMinutes, countryCodeToFlag } from '@/lib/steam';
|
||||
import { getSteamSummary, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
|
||||
import { getRaceTimerSummary } from '@/lib/racetimer';
|
||||
|
||||
export default async function PlayerDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ steamid: string }>;
|
||||
}) {
|
||||
const { steamid: rawSteamid } = await params;
|
||||
const steamid = decodeURIComponent(rawSteamid);
|
||||
|
||||
const player = await getPlayer(steamid);
|
||||
if (!player) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const steamId64 = steam2ToSteam64(steamid);
|
||||
const profileUrl = steamProfileUrl(steamid);
|
||||
|
||||
const [nameHistory, sessions, totalMinutes, steamSummary, raceTimer] = await Promise.all([
|
||||
getNameHistory(steamid),
|
||||
getSessions(steamid),
|
||||
getTotalPlaytimeMinutes(steamid),
|
||||
steamId64 ? getSteamSummary(steamId64) : Promise.resolve(null),
|
||||
getRaceTimerSummary(steamid),
|
||||
]);
|
||||
|
||||
const avatarUrl = steamSummary?.avatarUrl ?? DEFAULT_AVATAR_URL;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Link href="/players" className="text-sm text-ink-muted hover:text-accent transition-colors">
|
||||
← Back to players
|
||||
</Link>
|
||||
|
||||
{/* Header: avatar, name, steamid, profile link, total playtime */}
|
||||
<div className="panel p-6 flex flex-wrap items-center gap-6">
|
||||
<div className="w-20 h-20 rounded-lg overflow-hidden bg-base border border-base-border shrink-0">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={avatarUrl} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl text-ink">{player.current_name}</h1>
|
||||
<span className="text-lg" aria-hidden>
|
||||
{countryCodeToFlag(player.current_country)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mono text-ink-faint mt-1">{steamid}</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{profileUrl && (
|
||||
<a
|
||||
href={profileUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-accent hover:underline"
|
||||
>
|
||||
View Steam profile ↗
|
||||
</a>
|
||||
)}
|
||||
{raceTimer && (
|
||||
<a
|
||||
href={raceTimer.profileUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-accent hover:underline"
|
||||
>
|
||||
View RaceTimer profile ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="stat-label">Total playtime</div>
|
||||
<div className="text-2xl text-ink mono">
|
||||
{totalMinutes != null ? formatMinutes(totalMinutes) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{raceTimer && (
|
||||
<div className="text-right border-l border-base-border pl-6">
|
||||
<div className="stat-label">RaceTimer</div>
|
||||
<div className="text-2xl text-ink mono">Lv {raceTimer.level}</div>
|
||||
<div className="text-xs text-ink-faint mono">Rank #{raceTimer.rank}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-right border-l border-base-border pl-6">
|
||||
<div className="stat-label">Player Tier</div>
|
||||
<div className="text-2xl text-ink mono">{player.player_tier}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Previous names */}
|
||||
{nameHistory.length > 1 && (
|
||||
<div className="panel p-6">
|
||||
<div className="stat-label mb-3">Previous names</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{nameHistory
|
||||
.filter((n) => n.player_name !== player.current_name)
|
||||
.map((n) => (
|
||||
<span
|
||||
key={n.player_name}
|
||||
className="text-sm bg-base border border-base-border rounded px-3 py-1 text-ink-muted"
|
||||
title={`Last seen ${new Date(n.last_seen.replace(' ', 'T')).toLocaleString()}`}
|
||||
>
|
||||
{n.player_name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sessions */}
|
||||
<div className="panel p-6">
|
||||
<div className="stat-label mb-3">Play sessions</div>
|
||||
{sessions.length === 0 ? (
|
||||
<div className="text-sm text-ink-muted">No recorded sessions yet.</div>
|
||||
) : (
|
||||
<div className="divide-y divide-base-border">
|
||||
<div className="grid grid-cols-[1fr_1fr_100px] gap-4 pb-2 text-xs text-ink-faint uppercase tracking-wide">
|
||||
<div>Started</div>
|
||||
<div>Map(s)</div>
|
||||
<div className="text-right">Playtime</div>
|
||||
</div>
|
||||
{sessions.map((s) => (
|
||||
<div key={s.session_id} className="grid grid-cols-[1fr_1fr_100px] gap-4 py-3 text-sm items-center">
|
||||
<div>
|
||||
<div className="text-ink">
|
||||
{new Date(s.session_start_dt.replace(' ', 'T')).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-ink-faint">
|
||||
{s.session_end_dt
|
||||
? `until ${new Date(s.session_end_dt.replace(' ', 'T')).toLocaleTimeString()}`
|
||||
: 'still connected'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-ink-muted">{s.maps_played ?? '—'}</div>
|
||||
<div className="text-right mono text-ink">
|
||||
{formatMinutes(s.session_active_minutes)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import PlayerSearch from '@/components/PlayerSearch';
|
||||
|
||||
export default function PlayersPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl text-ink mb-1">Players</h1>
|
||||
<p className="text-sm text-ink-muted">
|
||||
Search by current or previous name, or SteamID.
|
||||
</p>
|
||||
</div>
|
||||
<PlayerSearch />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user