2026-05-06 16:03:37 +00:00

295 lines
11 KiB
TypeScript

import {
mdiChartBoxOutline,
mdiCloseCircleOutline,
mdiGamepadSquareOutline,
mdiHistory,
mdiShieldCheckOutline,
mdiTimerOutline,
} from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement } from 'react';
import axios from 'axios';
import { useRouter } from 'next/router';
import BaseButton from '../../../components/BaseButton';
import BaseIcon from '../../../components/BaseIcon';
import CardBox from '../../../components/CardBox';
import GuessCard from '../../../components/OsuHigherLower/GuessCard';
import { GameplayRound, GameplaySession } from '../../../components/OsuHigherLower/types';
import SectionMain from '../../../components/SectionMain';
import SectionTitleLineWithButton from '../../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../../config';
import LayoutAuthenticated from '../../../layouts/Authenticated';
const formatModeSelection = (modeSelection: GameplaySession['modeSelection']) => {
if (modeSelection === 'beatmap_only') {
return 'Beatmap';
}
if (modeSelection === 'mapper_only') {
return 'Mapper';
}
if (modeSelection === 'artist_only') {
return 'Artist';
}
return 'Mixed';
};
const formatSessionDate = (date?: string | null) => {
if (!date) {
return 'Still running';
}
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(date));
};
const resultTone = (round: GameplayRound) => {
if (round.reveal?.loseReason === 'timeout') {
return 'border-yellow-500/30 bg-yellow-500/10 text-yellow-100';
}
return round.reveal?.isCorrect
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-100'
: 'border-red-500/30 bg-red-500/10 text-red-100';
};
export default function SessionDetailPage() {
const router = useRouter();
const { sessionId } = router.query;
const [session, setSession] = React.useState<
(GameplaySession & {
leaderboardEntry?: {
score: number;
bestStreak: number;
roundsSurvived: number;
achievedAt: string;
} | null;
}) | null
>(null);
const [rounds, setRounds] = React.useState<GameplayRound[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const [errorMessage, setErrorMessage] = React.useState('');
React.useEffect(() => {
if (!sessionId || typeof sessionId !== 'string') {
return;
}
let isMounted = true;
const loadDetail = async () => {
try {
setIsLoading(true);
setErrorMessage('');
const { data } = await axios.get(`/gameplay/sessions/${sessionId}`);
if (!isMounted) {
return;
}
setSession(data.session);
setRounds(Array.isArray(data.rounds) ? data.rounds : []);
} catch (error: any) {
if (!isMounted) {
return;
}
setErrorMessage(error?.response?.data || 'Could not load this session.');
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
void loadDetail();
return () => {
isMounted = false;
};
}, [sessionId]);
return (
<>
<Head>
<title>{getPageTitle('Run detail')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiHistory} main title="Run detail">
<div className="flex flex-wrap items-center gap-3">
<BaseButton color="whiteDark" href="/play/history" label="Back to history" />
<BaseButton
color="info"
href="/play"
icon={mdiGamepadSquareOutline}
label="Open arena"
/>
</div>
</SectionTitleLineWithButton>
{errorMessage ? (
<div className="mb-6 rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-100">
{errorMessage}
</div>
) : null}
{isLoading ? (
<CardBox className="border border-white/10 bg-[#10141d] text-white">
<div className="py-10 text-center text-sm text-white/55">
Loading session detail...
</div>
</CardBox>
) : null}
{!isLoading && session ? (
<div className="space-y-6">
<CardBox className="border border-white/10 bg-[#10141d] text-white">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-sm uppercase tracking-[0.22em] text-white/45">
{formatModeSelection(session.modeSelection)} ·{' '}
{session.timerProfile === 'insane' ? 'Insane · 5s' : 'Normal · 8s'}
</p>
<h2 className="mt-2 text-3xl font-semibold text-white">
{session.correctGuesses} correct guesses across {session.roundsPlayed} rounds.
</h2>
<p className="mt-3 text-base leading-7 text-white/65">
Started {formatSessionDate(session.startedAt)} · Ended{' '}
{formatSessionDate(session.endedAt)}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-3 lg:w-[460px]">
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="flex items-center gap-2">
<BaseIcon className="text-white/55" path={mdiShieldCheckOutline} size={16} />
<p className="text-[11px] uppercase tracking-[0.16em] text-white/45">
Best streak
</p>
</div>
<p className="mt-2 text-2xl font-semibold text-white">
{session.bestStreak}
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="flex items-center gap-2">
<BaseIcon className="text-white/55" path={mdiChartBoxOutline} size={16} />
<p className="text-[11px] uppercase tracking-[0.16em] text-white/45">
Score
</p>
</div>
<p className="mt-2 text-2xl font-semibold text-white">
{session.leaderboardEntry?.score ?? session.correctGuesses}
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="flex items-center gap-2">
<BaseIcon className="text-white/55" path={mdiTimerOutline} size={16} />
<p className="text-[11px] uppercase tracking-[0.16em] text-white/45">
Status
</p>
</div>
<p className="mt-2 text-2xl font-semibold capitalize text-white">
{session.status}
</p>
</div>
</div>
</div>
</CardBox>
{!rounds.length ? (
<CardBox className="border border-white/10 bg-[#10141d] text-white">
<div className="py-10 text-center text-sm text-white/55">
No rounds were recorded for this session.
</div>
</CardBox>
) : null}
{rounds.map((round) => (
<CardBox className="border border-white/10 bg-[#10141d] text-white" key={round.id}>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2.5">
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] text-white/60">
Round {round.roundNumber}
</span>
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] text-white/60">
{round.reveal?.modeLabel}
</span>
<span
className={`rounded-full border px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] ${resultTone(round)}`}
>
{round.reveal?.loseReason === 'timeout'
? 'Timed out'
: round.reveal?.isCorrect
? 'Correct'
: 'Wrong'}
</span>
</div>
<p className="mt-3 text-sm leading-7 text-white/62">
{round.reveal?.loseReason === 'timeout'
? 'The timer expired before an answer reached the server.'
: `You chose ${String(round.reveal?.playerChoice).toUpperCase()} and the correct answer was ${String(round.reveal?.correctChoice).toUpperCase()}.`}
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/65">
Answered {formatSessionDate(round.reveal?.answeredAt || round.presentedAt)}
</div>
</div>
<div className="mt-5 grid gap-5 lg:grid-cols-2">
<GuessCard
card={round.cards.a}
compact
reveal={{
show: true,
value: round.reveal?.values.a || 0,
valueSuffix: round.reveal?.valueSuffix || 'plays',
isWinner: round.reveal?.winningChoice === 'a',
isWrongSelection:
!round.reveal?.isCorrect && round.reveal?.playerChoice === 'a',
}}
slot="a"
/>
<GuessCard
card={round.cards.b}
compact
reveal={{
show: true,
value: round.reveal?.values.b || 0,
valueSuffix: round.reveal?.valueSuffix || 'plays',
isWinner: round.reveal?.winningChoice === 'b',
isWrongSelection:
!round.reveal?.isCorrect && round.reveal?.playerChoice === 'b',
}}
slot="b"
/>
</div>
</CardBox>
))}
</div>
) : null}
{!isLoading && !session && !errorMessage ? (
<CardBox className="border border-white/10 bg-[#10141d] text-white">
<div className="py-10 text-center text-sm text-white/55">
This session could not be found.
</div>
</CardBox>
) : null}
</SectionMain>
</>
);
}
SessionDetailPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission="READ_GAME_SESSIONS">{page}</LayoutAuthenticated>;
};