295 lines
11 KiB
TypeScript
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>;
|
|
};
|