initial commit of the paytime session frontend
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user