Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
Binary file not shown.
|
Before Width: | Height: | Size: 270 KiB |
@ -6,6 +6,7 @@ const passport = require('passport');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
|
const db = require('./db/models');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const swaggerUI = require('swagger-ui-express');
|
const swaggerUI = require('swagger-ui-express');
|
||||||
const swaggerJsDoc = require('swagger-jsdoc');
|
const swaggerJsDoc = require('swagger-jsdoc');
|
||||||
@ -53,7 +54,6 @@ const leaderboard_entriesRoutes = require('./routes/leaderboard_entries');
|
|||||||
const daily_seedsRoutes = require('./routes/daily_seeds');
|
const daily_seedsRoutes = require('./routes/daily_seeds');
|
||||||
|
|
||||||
const server_cachesRoutes = require('./routes/server_caches');
|
const server_cachesRoutes = require('./routes/server_caches');
|
||||||
const gameplayRoutes = require('./routes/gameplay');
|
|
||||||
|
|
||||||
|
|
||||||
const getBaseUrl = (url) => {
|
const getBaseUrl = (url) => {
|
||||||
@ -146,7 +146,6 @@ app.use('/api/leaderboard_entries', passport.authenticate('jwt', {session: false
|
|||||||
app.use('/api/daily_seeds', passport.authenticate('jwt', {session: false}), daily_seedsRoutes);
|
app.use('/api/daily_seeds', passport.authenticate('jwt', {session: false}), daily_seedsRoutes);
|
||||||
|
|
||||||
app.use('/api/server_caches', passport.authenticate('jwt', {session: false}), server_cachesRoutes);
|
app.use('/api/server_caches', passport.authenticate('jwt', {session: false}), server_cachesRoutes);
|
||||||
app.use('/api/gameplay', passport.authenticate('jwt', {session: false}), gameplayRoutes);
|
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
'/api/openai',
|
'/api/openai',
|
||||||
|
|||||||
@ -1,58 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
|
|
||||||
const GameplayService = require('../services/gameplay');
|
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/active',
|
|
||||||
wrapAsync(async (req, res) => {
|
|
||||||
const payload = await GameplayService.getActiveSession(req.currentUser);
|
|
||||||
res.status(200).send(payload);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/sessions',
|
|
||||||
wrapAsync(async (req, res) => {
|
|
||||||
const payload = await GameplayService.listSessions(req.currentUser);
|
|
||||||
res.status(200).send(payload);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/sessions/:id',
|
|
||||||
wrapAsync(async (req, res) => {
|
|
||||||
const payload = await GameplayService.getSessionDetail(
|
|
||||||
req.params.id,
|
|
||||||
req.currentUser,
|
|
||||||
);
|
|
||||||
res.status(200).send(payload);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/sessions/start',
|
|
||||||
wrapAsync(async (req, res) => {
|
|
||||||
const payload = await GameplayService.startSession(req.body, req.currentUser);
|
|
||||||
res.status(200).send(payload);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/sessions/:sessionId/rounds/:roundId/answer',
|
|
||||||
wrapAsync(async (req, res) => {
|
|
||||||
const payload = await GameplayService.answerRound(
|
|
||||||
req.params.sessionId,
|
|
||||||
req.params.roundId,
|
|
||||||
req.body,
|
|
||||||
req.currentUser,
|
|
||||||
);
|
|
||||||
res.status(200).send(payload);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
router.use('/', require('../helpers').commonErrorHandler);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,773 +0,0 @@
|
|||||||
const axios = require('axios');
|
|
||||||
|
|
||||||
const db = require('../db/models');
|
|
||||||
|
|
||||||
const { Op } = db.Sequelize;
|
|
||||||
|
|
||||||
const OSU_PUBLIC_BEATMAPSET_SEARCH_URL = 'https://osu.ppy.sh/beatmapsets/search';
|
|
||||||
const SUPPORTED_SET_STATUSES = new Set(['ranked', 'loved']);
|
|
||||||
const DEFAULT_SEED_PAGES = 12;
|
|
||||||
const DEFAULT_INCREMENTAL_PAGES = 3;
|
|
||||||
const MAX_PAGES_PER_JOB = 40;
|
|
||||||
const DEFAULT_START_PAGE = 1;
|
|
||||||
const REQUEST_TIMEOUT_MS = 30000;
|
|
||||||
const IMPORT_JOB_TYPES = ['osu_sync_seed', 'osu_sync_incremental'];
|
|
||||||
|
|
||||||
function syncError(message, statusCode = 400) {
|
|
||||||
const error = new Error(message);
|
|
||||||
error.statusCode = statusCode;
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeInteger(value, fallback = 0) {
|
|
||||||
const normalized = Number(value);
|
|
||||||
return Number.isFinite(normalized) ? Math.trunc(normalized) : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDecimal(value, fallback = 0) {
|
|
||||||
const normalized = Number(value);
|
|
||||||
return Number.isFinite(normalized) ? normalized : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeString(value) {
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSetStatus(status) {
|
|
||||||
const normalized = normalizeString(status).toLowerCase();
|
|
||||||
return SUPPORTED_SET_STATUSES.has(normalized) ? normalized : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMapperAvatarUrl(osuUserNumeric) {
|
|
||||||
if (!osuUserNumeric) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `https://a.ppy.sh/${osuUserNumeric}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMapperProfileUrl(osuUserNumeric) {
|
|
||||||
if (!osuUserNumeric) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `https://osu.ppy.sh/users/${osuUserNumeric}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBestCoverUrl(beatmapSet) {
|
|
||||||
return (
|
|
||||||
beatmapSet?.covers?.card ||
|
|
||||||
beatmapSet?.covers?.['card@2x'] ||
|
|
||||||
beatmapSet?.covers?.cover ||
|
|
||||||
beatmapSet?.covers?.['cover@2x'] ||
|
|
||||||
beatmapSet?.covers?.list ||
|
|
||||||
beatmapSet?.covers?.['list@2x'] ||
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDateOrNull(value) {
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = new Date(value);
|
|
||||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJobPayload(rawPayload) {
|
|
||||||
if (!rawPayload) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof rawPayload === 'object' && !Array.isArray(rawPayload)) {
|
|
||||||
return rawPayload;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof rawPayload !== 'string') {
|
|
||||||
throw syncError('job_payload_json must be empty or valid JSON.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(rawPayload);
|
|
||||||
|
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
||||||
throw syncError('job_payload_json must decode to a JSON object.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
} catch (error) {
|
|
||||||
if (error.statusCode) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw syncError('job_payload_json must contain valid JSON.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultPageCount(jobType) {
|
|
||||||
return jobType === 'osu_sync_incremental'
|
|
||||||
? DEFAULT_INCREMENTAL_PAGES
|
|
||||||
: DEFAULT_SEED_PAGES;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNormalizedPageCount(payload, jobType) {
|
|
||||||
return Math.min(
|
|
||||||
Math.max(normalizeInteger(payload?.pages, getDefaultPageCount(jobType)), 1),
|
|
||||||
MAX_PAGES_PER_JOB,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNormalizedSort(payload) {
|
|
||||||
return normalizeString(payload?.sort || 'plays_desc') || 'plays_desc';
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasExplicitStartPage(payload) {
|
|
||||||
const rawStartPage = payload?.startPage ?? payload?.page;
|
|
||||||
|
|
||||||
if (rawStartPage === null || rawStartPage === undefined) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${rawStartPage}`.trim() !== '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNormalizedStartPage(payload) {
|
|
||||||
return Math.max(
|
|
||||||
normalizeInteger(payload?.startPage ?? payload?.page, DEFAULT_START_PAGE),
|
|
||||||
DEFAULT_START_PAGE,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getNextAutomaticStartPage({ currentJobId, sort }) {
|
|
||||||
const previousSuccessfulJobs = await db.sync_jobs.findAll({
|
|
||||||
attributes: ['id', 'job_type', 'job_payload_json'],
|
|
||||||
where: {
|
|
||||||
id: {
|
|
||||||
[Op.ne]: currentJobId,
|
|
||||||
},
|
|
||||||
job_type: {
|
|
||||||
[Op.in]: IMPORT_JOB_TYPES,
|
|
||||||
},
|
|
||||||
status: 'succeeded',
|
|
||||||
},
|
|
||||||
order: [['createdAt', 'ASC']],
|
|
||||||
});
|
|
||||||
|
|
||||||
return previousSuccessfulJobs.reduce((nextStartPage, previousJob) => {
|
|
||||||
let previousPayload;
|
|
||||||
|
|
||||||
try {
|
|
||||||
previousPayload = parseJobPayload(previousJob.job_payload_json);
|
|
||||||
} catch (error) {
|
|
||||||
return nextStartPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (getNormalizedSort(previousPayload) !== sort) {
|
|
||||||
return nextStartPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousStartPage = hasExplicitStartPage(previousPayload)
|
|
||||||
? getNormalizedStartPage(previousPayload)
|
|
||||||
: DEFAULT_START_PAGE;
|
|
||||||
const previousPages = getNormalizedPageCount(previousPayload, previousJob.job_type);
|
|
||||||
const previousNextPage = previousStartPage + previousPages;
|
|
||||||
|
|
||||||
return previousNextPage > nextStartPage ? previousNextPage : nextStartPage;
|
|
||||||
}, DEFAULT_START_PAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getImportOptions(jobRecord) {
|
|
||||||
const payload = parseJobPayload(jobRecord?.job_payload_json);
|
|
||||||
const pages = getNormalizedPageCount(payload, jobRecord?.job_type);
|
|
||||||
const sort = getNormalizedSort(payload);
|
|
||||||
const explicitStartPage = hasExplicitStartPage(payload);
|
|
||||||
const startPage = explicitStartPage
|
|
||||||
? getNormalizedStartPage(payload)
|
|
||||||
: await getNextAutomaticStartPage({
|
|
||||||
currentJobId: jobRecord?.id,
|
|
||||||
sort,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
payload,
|
|
||||||
pages,
|
|
||||||
sort,
|
|
||||||
startPage,
|
|
||||||
explicitStartPage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldKeepBeatmapSet(beatmapSet) {
|
|
||||||
if (!beatmapSet || !normalizeInteger(beatmapSet.id, 0)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!normalizeSetStatus(beatmapSet.status)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!normalizeString(beatmapSet.title) || !normalizeString(beatmapSet.artist)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUsableBeatmaps(beatmapSet) {
|
|
||||||
const setStatus = normalizeSetStatus(beatmapSet?.status);
|
|
||||||
|
|
||||||
return (Array.isArray(beatmapSet?.beatmaps) ? beatmapSet.beatmaps : []).filter(
|
|
||||||
(beatmap) => {
|
|
||||||
if (!beatmap || !normalizeInteger(beatmap.id, 0)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const beatmapStatus = normalizeSetStatus(beatmap.status) || setStatus;
|
|
||||||
|
|
||||||
return (
|
|
||||||
beatmap.mode === 'osu' &&
|
|
||||||
Boolean(beatmap.is_scoreable) &&
|
|
||||||
Boolean(beatmapStatus)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateJob(jobId, currentUser, patch) {
|
|
||||||
await db.sync_jobs.update(
|
|
||||||
{
|
|
||||||
...patch,
|
|
||||||
updatedById: currentUser?.id || null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
where: { id: jobId },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toRecordMap(records, getKey) {
|
|
||||||
return records.reduce((accumulator, record) => {
|
|
||||||
const key = getKey(record);
|
|
||||||
|
|
||||||
if (key !== null && key !== undefined && key !== '') {
|
|
||||||
accumulator.set(key, record);
|
|
||||||
}
|
|
||||||
|
|
||||||
return accumulator;
|
|
||||||
}, new Map());
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchBeatmapSearchPage({ page, sort }) {
|
|
||||||
let response;
|
|
||||||
|
|
||||||
try {
|
|
||||||
response = await axios.get(OSU_PUBLIC_BEATMAPSET_SEARCH_URL, {
|
|
||||||
params: {
|
|
||||||
m: 0,
|
|
||||||
nsfw: false,
|
|
||||||
sort,
|
|
||||||
page,
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Flatlogic Osu Higher Lower Catalog Sync',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
timeout: REQUEST_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const remoteMessage = error?.response?.data?.error;
|
|
||||||
|
|
||||||
throw syncError(
|
|
||||||
remoteMessage || 'Unable to download beatmap catalog data from osu! right now.',
|
|
||||||
error?.response?.status || 502,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const beatmapSets = Array.isArray(response?.data?.beatmapsets)
|
|
||||||
? response.data.beatmapsets
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return beatmapSets.filter(shouldKeepBeatmapSet);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upsertBeatmapSetsPage({ beatmapSets, currentUser }) {
|
|
||||||
const transaction = await db.sequelize.transaction();
|
|
||||||
const now = new Date();
|
|
||||||
const counts = {
|
|
||||||
processed: 0,
|
|
||||||
inserted: 0,
|
|
||||||
updated: 0,
|
|
||||||
skipped: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const osuBeatmapSetNumerics = beatmapSets
|
|
||||||
.map((beatmapSet) => normalizeInteger(beatmapSet.id, 0))
|
|
||||||
.filter(Boolean);
|
|
||||||
const creatorUserNumerics = beatmapSets
|
|
||||||
.map((beatmapSet) => normalizeInteger(beatmapSet.user_id, 0))
|
|
||||||
.filter(Boolean);
|
|
||||||
const artistNames = beatmapSets
|
|
||||||
.map((beatmapSet) => normalizeString(beatmapSet.artist))
|
|
||||||
.filter(Boolean);
|
|
||||||
const osuBeatmapNumerics = beatmapSets.flatMap((beatmapSet) =>
|
|
||||||
getUsableBeatmaps(beatmapSet)
|
|
||||||
.map((beatmap) => normalizeInteger(beatmap.id, 0))
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
|
|
||||||
const [existingSets, existingMappers, existingArtists, existingBeatmaps] =
|
|
||||||
await Promise.all([
|
|
||||||
db.beatmap_sets.findAll({
|
|
||||||
where: {
|
|
||||||
osu_beatmapset_numeric: {
|
|
||||||
[Op.in]: osuBeatmapSetNumerics,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
}),
|
|
||||||
creatorUserNumerics.length
|
|
||||||
? db.mappers.findAll({
|
|
||||||
where: {
|
|
||||||
osu_user_numeric: {
|
|
||||||
[Op.in]: creatorUserNumerics,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
})
|
|
||||||
: [],
|
|
||||||
artistNames.length
|
|
||||||
? db.artists.findAll({
|
|
||||||
where: {
|
|
||||||
name: {
|
|
||||||
[Op.in]: artistNames,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
})
|
|
||||||
: [],
|
|
||||||
osuBeatmapNumerics.length
|
|
||||||
? db.beatmaps.findAll({
|
|
||||||
where: {
|
|
||||||
osu_beatmap_numeric: {
|
|
||||||
[Op.in]: osuBeatmapNumerics,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
})
|
|
||||||
: [],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const beatmapSetMap = toRecordMap(
|
|
||||||
existingSets,
|
|
||||||
(record) => normalizeInteger(record.osu_beatmapset_numeric, 0),
|
|
||||||
);
|
|
||||||
const mapperMap = toRecordMap(
|
|
||||||
existingMappers,
|
|
||||||
(record) => normalizeInteger(record.osu_user_numeric, 0),
|
|
||||||
);
|
|
||||||
const artistMap = toRecordMap(existingArtists, (record) => normalizeString(record.name));
|
|
||||||
const beatmapMap = toRecordMap(
|
|
||||||
existingBeatmaps,
|
|
||||||
(record) => normalizeInteger(record.osu_beatmap_numeric, 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const beatmapSet of beatmapSets) {
|
|
||||||
const osuBeatmapSetNumeric = normalizeInteger(beatmapSet.id, 0);
|
|
||||||
const creatorUserNumeric = normalizeInteger(beatmapSet.user_id, 0);
|
|
||||||
const artistName = normalizeString(beatmapSet.artist);
|
|
||||||
const setStatus = normalizeSetStatus(beatmapSet.status);
|
|
||||||
const usableBeatmaps = getUsableBeatmaps(beatmapSet);
|
|
||||||
const coverUrl = getBestCoverUrl(beatmapSet);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!osuBeatmapSetNumeric ||
|
|
||||||
!creatorUserNumeric ||
|
|
||||||
!artistName ||
|
|
||||||
!setStatus ||
|
|
||||||
!usableBeatmaps.length
|
|
||||||
) {
|
|
||||||
counts.skipped += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
counts.processed += 1;
|
|
||||||
|
|
||||||
const beatmapSetPayload = {
|
|
||||||
osu_beatmapset_numeric: osuBeatmapSetNumeric,
|
|
||||||
title: normalizeString(beatmapSet.title) || 'Unknown beatmap set',
|
|
||||||
artist_name: artistName,
|
|
||||||
creator_username: normalizeString(beatmapSet.creator) || 'Unknown mapper',
|
|
||||||
creator_user_numeric: creatorUserNumeric,
|
|
||||||
status: setStatus,
|
|
||||||
total_playcount: normalizeInteger(beatmapSet.play_count, 0),
|
|
||||||
background_image_url: coverUrl,
|
|
||||||
cover_image_url: coverUrl,
|
|
||||||
ranked_at: setStatus === 'ranked' ? parseDateOrNull(beatmapSet.ranked_date) : null,
|
|
||||||
loved_at: setStatus === 'loved' ? parseDateOrNull(beatmapSet.ranked_date) : null,
|
|
||||||
last_synced_at: now,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
let beatmapSetRecord = beatmapSetMap.get(osuBeatmapSetNumeric);
|
|
||||||
|
|
||||||
if (beatmapSetRecord) {
|
|
||||||
await beatmapSetRecord.update(beatmapSetPayload, { transaction });
|
|
||||||
counts.updated += 1;
|
|
||||||
} else {
|
|
||||||
beatmapSetRecord = await db.beatmap_sets.create(
|
|
||||||
{
|
|
||||||
...beatmapSetPayload,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
beatmapSetMap.set(osuBeatmapSetNumeric, beatmapSetRecord);
|
|
||||||
counts.inserted += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapperPayload = {
|
|
||||||
osu_user_numeric: creatorUserNumeric,
|
|
||||||
osu_username: normalizeString(beatmapSet.creator) || 'Unknown mapper',
|
|
||||||
avatar_url: getMapperAvatarUrl(creatorUserNumeric),
|
|
||||||
profile_url: getMapperProfileUrl(creatorUserNumeric),
|
|
||||||
banner_url: coverUrl,
|
|
||||||
status: 'active',
|
|
||||||
last_synced_at: now,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mapperRecord = mapperMap.get(creatorUserNumeric);
|
|
||||||
|
|
||||||
if (mapperRecord) {
|
|
||||||
await mapperRecord.update(mapperPayload, { transaction });
|
|
||||||
counts.updated += 1;
|
|
||||||
} else {
|
|
||||||
mapperRecord = await db.mappers.create(
|
|
||||||
{
|
|
||||||
...mapperPayload,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
mapperMap.set(creatorUserNumeric, mapperRecord);
|
|
||||||
counts.inserted += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const artistPayload = {
|
|
||||||
name: artistName,
|
|
||||||
image_source: coverUrl ? 'beatmap_background_fallback' : 'none',
|
|
||||||
image_url: coverUrl,
|
|
||||||
external_url: null,
|
|
||||||
status: 'active',
|
|
||||||
last_synced_at: now,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
let artistRecord = artistMap.get(artistName);
|
|
||||||
|
|
||||||
if (artistRecord) {
|
|
||||||
await artistRecord.update(artistPayload, { transaction });
|
|
||||||
counts.updated += 1;
|
|
||||||
} else {
|
|
||||||
artistRecord = await db.artists.create(
|
|
||||||
{
|
|
||||||
...artistPayload,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
artistMap.set(artistName, artistRecord);
|
|
||||||
counts.inserted += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const beatmap of usableBeatmaps) {
|
|
||||||
const osuBeatmapNumeric = normalizeInteger(beatmap.id, 0);
|
|
||||||
const beatmapStatus = normalizeSetStatus(beatmap.status) || setStatus;
|
|
||||||
|
|
||||||
if (!osuBeatmapNumeric || !beatmapStatus) {
|
|
||||||
counts.skipped += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const beatmapPayload = {
|
|
||||||
osu_beatmap_numeric: osuBeatmapNumeric,
|
|
||||||
difficulty_name: normalizeString(beatmap.version) || 'Unknown difficulty',
|
|
||||||
mode: 'osu',
|
|
||||||
status: beatmapStatus,
|
|
||||||
playcount: normalizeInteger(beatmap.playcount, 0),
|
|
||||||
passcount: normalizeInteger(beatmap.passcount, 0),
|
|
||||||
star_rating: normalizeDecimal(beatmap.difficulty_rating, 0),
|
|
||||||
bpm: normalizeDecimal(beatmap.bpm || beatmapSet.bpm, 0),
|
|
||||||
length_seconds: normalizeInteger(
|
|
||||||
beatmap.total_length || beatmap.hit_length,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
cs: normalizeDecimal(beatmap.cs, 0),
|
|
||||||
ar: normalizeDecimal(beatmap.ar, 0),
|
|
||||||
od: normalizeDecimal(beatmap.accuracy, 0),
|
|
||||||
hp: normalizeDecimal(beatmap.drain, 0),
|
|
||||||
last_synced_at: now,
|
|
||||||
beatmap_setId: beatmapSetRecord.id,
|
|
||||||
mapperId: mapperRecord.id,
|
|
||||||
artistId: artistRecord.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
let beatmapRecord = beatmapMap.get(osuBeatmapNumeric);
|
|
||||||
|
|
||||||
if (beatmapRecord) {
|
|
||||||
await beatmapRecord.update(beatmapPayload, { transaction });
|
|
||||||
counts.updated += 1;
|
|
||||||
} else {
|
|
||||||
beatmapRecord = await db.beatmaps.create(
|
|
||||||
{
|
|
||||||
...beatmapPayload,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
beatmapMap.set(osuBeatmapNumeric, beatmapRecord);
|
|
||||||
counts.inserted += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await transaction.commit();
|
|
||||||
return counts;
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function recomputeEntityStats({
|
|
||||||
entity = 'mapper',
|
|
||||||
currentUser,
|
|
||||||
}) {
|
|
||||||
const foreignKey = entity === 'artist' ? 'artistId' : 'mapperId';
|
|
||||||
const StatsModel = entity === 'artist' ? db.artist_stats : db.mapper_stats;
|
|
||||||
const transaction = await db.sequelize.transaction();
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const aggregates = await db.beatmaps.findAll({
|
|
||||||
attributes: [
|
|
||||||
foreignKey,
|
|
||||||
[db.sequelize.fn('SUM', db.sequelize.col('playcount')), 'total_plays'],
|
|
||||||
[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'ranked_loved_maps_count'],
|
|
||||||
],
|
|
||||||
where: {
|
|
||||||
[foreignKey]: {
|
|
||||||
[Op.ne]: null,
|
|
||||||
},
|
|
||||||
mode: 'osu',
|
|
||||||
status: {
|
|
||||||
[Op.in]: ['ranked', 'loved'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
group: [foreignKey],
|
|
||||||
raw: true,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
const entityIds = aggregates
|
|
||||||
.map((row) => row[foreignKey])
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const existingStats = entityIds.length
|
|
||||||
? await StatsModel.findAll({
|
|
||||||
where: {
|
|
||||||
[foreignKey]: {
|
|
||||||
[Op.in]: entityIds,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
order: [['createdAt', 'ASC'], ['id', 'ASC']],
|
|
||||||
transaction,
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const existingStatsMap = existingStats.reduce((accumulator, statRecord) => {
|
|
||||||
const relatedId = statRecord[foreignKey];
|
|
||||||
|
|
||||||
if (!accumulator.has(relatedId)) {
|
|
||||||
accumulator.set(relatedId, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
accumulator.get(relatedId).push(statRecord);
|
|
||||||
return accumulator;
|
|
||||||
}, new Map());
|
|
||||||
|
|
||||||
let inserted = 0;
|
|
||||||
let updated = 0;
|
|
||||||
let removedDuplicates = 0;
|
|
||||||
|
|
||||||
for (const aggregate of aggregates) {
|
|
||||||
const relatedId = aggregate[foreignKey];
|
|
||||||
const relatedStats = existingStatsMap.get(relatedId) || [];
|
|
||||||
const [primaryRecord, ...duplicateRecords] = relatedStats;
|
|
||||||
const payload = {
|
|
||||||
total_plays: normalizeInteger(aggregate.total_plays, 0),
|
|
||||||
ranked_loved_maps_count: normalizeInteger(
|
|
||||||
aggregate.ranked_loved_maps_count,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
computed_at: now,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (primaryRecord) {
|
|
||||||
await primaryRecord.update(payload, { transaction });
|
|
||||||
updated += 1;
|
|
||||||
} else {
|
|
||||||
await StatsModel.create(
|
|
||||||
{
|
|
||||||
...payload,
|
|
||||||
[foreignKey]: relatedId,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
inserted += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const duplicateRecord of duplicateRecords) {
|
|
||||||
await duplicateRecord.destroy({ transaction });
|
|
||||||
removedDuplicates += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await transaction.commit();
|
|
||||||
|
|
||||||
return {
|
|
||||||
inserted,
|
|
||||||
updated,
|
|
||||||
removedDuplicates,
|
|
||||||
processed: aggregates.length,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = class OsuCatalogSyncService {
|
|
||||||
static isRunnableJobType(jobType) {
|
|
||||||
return [
|
|
||||||
'osu_sync_seed',
|
|
||||||
'osu_sync_incremental',
|
|
||||||
'aggregate_mapper_totals',
|
|
||||||
'aggregate_artist_totals',
|
|
||||||
].includes(jobType);
|
|
||||||
}
|
|
||||||
|
|
||||||
static async run(jobRecord, currentUser) {
|
|
||||||
if (!jobRecord?.id) {
|
|
||||||
throw syncError('Sync job record is required.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jobRecord.job_type === 'aggregate_mapper_totals') {
|
|
||||||
const mapperTotals = await recomputeEntityStats({
|
|
||||||
entity: 'mapper',
|
|
||||||
currentUser,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
processedCount: mapperTotals.processed,
|
|
||||||
insertedCount: mapperTotals.inserted,
|
|
||||||
updatedCount: mapperTotals.updated,
|
|
||||||
skippedCount: 0,
|
|
||||||
summary: `Recomputed mapper totals for ${mapperTotals.processed} mappers.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jobRecord.job_type === 'aggregate_artist_totals') {
|
|
||||||
const artistTotals = await recomputeEntityStats({
|
|
||||||
entity: 'artist',
|
|
||||||
currentUser,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
processedCount: artistTotals.processed,
|
|
||||||
insertedCount: artistTotals.inserted,
|
|
||||||
updatedCount: artistTotals.updated,
|
|
||||||
skippedCount: 0,
|
|
||||||
summary: `Recomputed artist totals for ${artistTotals.processed} artists.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const importOptions = await getImportOptions(jobRecord);
|
|
||||||
const normalizedPayload = {
|
|
||||||
...importOptions.payload,
|
|
||||||
pages: importOptions.pages,
|
|
||||||
sort: importOptions.sort,
|
|
||||||
startPage: importOptions.startPage,
|
|
||||||
};
|
|
||||||
|
|
||||||
delete normalizedPayload.page;
|
|
||||||
|
|
||||||
await updateJob(jobRecord.id, currentUser, {
|
|
||||||
job_payload_json: JSON.stringify(normalizedPayload),
|
|
||||||
});
|
|
||||||
|
|
||||||
const totals = {
|
|
||||||
processedCount: 0,
|
|
||||||
insertedCount: 0,
|
|
||||||
updatedCount: 0,
|
|
||||||
skippedCount: 0,
|
|
||||||
errorCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let pageOffset = 0; pageOffset < importOptions.pages; pageOffset += 1) {
|
|
||||||
const page = importOptions.startPage + pageOffset;
|
|
||||||
const beatmapSets = await fetchBeatmapSearchPage({
|
|
||||||
page,
|
|
||||||
sort: importOptions.sort,
|
|
||||||
});
|
|
||||||
const pageCounts = await upsertBeatmapSetsPage({
|
|
||||||
beatmapSets,
|
|
||||||
currentUser,
|
|
||||||
});
|
|
||||||
|
|
||||||
totals.processedCount += pageCounts.processed;
|
|
||||||
totals.insertedCount += pageCounts.inserted;
|
|
||||||
totals.updatedCount += pageCounts.updated;
|
|
||||||
totals.skippedCount += pageCounts.skipped;
|
|
||||||
|
|
||||||
await updateJob(jobRecord.id, currentUser, {
|
|
||||||
processed_count: totals.processedCount,
|
|
||||||
inserted_count: totals.insertedCount,
|
|
||||||
updated_count: totals.updatedCount,
|
|
||||||
skipped_count: totals.skippedCount,
|
|
||||||
error_count: totals.errorCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const [mapperTotals, artistTotals] = await Promise.all([
|
|
||||||
recomputeEntityStats({ entity: 'mapper', currentUser }),
|
|
||||||
recomputeEntityStats({ entity: 'artist', currentUser }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
totals.insertedCount += mapperTotals.inserted + artistTotals.inserted;
|
|
||||||
totals.updatedCount += mapperTotals.updated + artistTotals.updated;
|
|
||||||
|
|
||||||
const endPage = importOptions.startPage + importOptions.pages - 1;
|
|
||||||
const pageRangeLabel =
|
|
||||||
importOptions.pages === 1
|
|
||||||
? `page ${importOptions.startPage}`
|
|
||||||
: `pages ${importOptions.startPage}-${endPage}`;
|
|
||||||
const nextSyncHint = importOptions.explicitStartPage
|
|
||||||
? ''
|
|
||||||
: ` Next automatic sync will continue from page ${endPage + 1}.`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...totals,
|
|
||||||
summary: `Imported ${totals.processedCount} beatmap sets across ${pageRangeLabel} (${importOptions.sort}) and refreshed mapper/artist totals.${nextSyncHint}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,107 +1,36 @@
|
|||||||
const db = require('../db/models');
|
const db = require('../db/models');
|
||||||
const Sync_jobsDBApi = require('../db/api/sync_jobs');
|
const Sync_jobsDBApi = require('../db/api/sync_jobs');
|
||||||
const processFile = require('../middlewares/upload');
|
const processFile = require("../middlewares/upload");
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
const OsuCatalogSyncService = require('./osuCatalogSync');
|
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
|
const axios = require('axios');
|
||||||
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
|
||||||
async function createStoredJob(data, currentUser) {
|
|
||||||
const transaction = await db.sequelize.transaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const syncJob = await Sync_jobsDBApi.create(
|
|
||||||
data,
|
|
||||||
{
|
|
||||||
currentUser,
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await transaction.commit();
|
|
||||||
return syncJob;
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markJobFailed(jobId, currentUser, error) {
|
|
||||||
try {
|
|
||||||
await db.sync_jobs.update(
|
|
||||||
{
|
|
||||||
status: 'failed',
|
|
||||||
finished_at: new Date(),
|
|
||||||
error_count: 1,
|
|
||||||
error_summary: error.message,
|
|
||||||
updatedById: currentUser?.id || null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
where: { id: jobId },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (updateError) {
|
|
||||||
console.error('Failed to update sync job failure state:', updateError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = class Sync_jobsService {
|
module.exports = class Sync_jobsService {
|
||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const shouldRunJob =
|
const transaction = await db.sequelize.transaction();
|
||||||
OsuCatalogSyncService.isRunnableJobType(data?.job_type) &&
|
|
||||||
(!data?.status || ['queued', 'running'].includes(data.status));
|
|
||||||
|
|
||||||
const initialPayload = shouldRunJob
|
|
||||||
? {
|
|
||||||
...data,
|
|
||||||
status: 'running',
|
|
||||||
started_at: new Date(),
|
|
||||||
finished_at: null,
|
|
||||||
processed_count: 0,
|
|
||||||
inserted_count: 0,
|
|
||||||
updated_count: 0,
|
|
||||||
skipped_count: 0,
|
|
||||||
error_count: 0,
|
|
||||||
error_summary: null,
|
|
||||||
triggered_by: currentUser?.id || null,
|
|
||||||
}
|
|
||||||
: data;
|
|
||||||
|
|
||||||
const syncJob = await createStoredJob(initialPayload, currentUser);
|
|
||||||
|
|
||||||
if (!shouldRunJob) {
|
|
||||||
return syncJob;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await OsuCatalogSyncService.run(syncJob, currentUser);
|
await Sync_jobsDBApi.create(
|
||||||
|
data,
|
||||||
await db.sync_jobs.update(
|
|
||||||
{
|
{
|
||||||
status: 'succeeded',
|
currentUser,
|
||||||
finished_at: new Date(),
|
transaction,
|
||||||
processed_count: result.processedCount,
|
|
||||||
inserted_count: result.insertedCount,
|
|
||||||
updated_count: result.updatedCount,
|
|
||||||
skipped_count: result.skippedCount,
|
|
||||||
error_count: result.errorCount || 0,
|
|
||||||
error_summary: result.summary,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
where: { id: syncJob.id },
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return db.sync_jobs.findByPk(syncJob.id);
|
await transaction.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sync job failed:', error);
|
await transaction.rollback();
|
||||||
await markJobFailed(syncJob.id, currentUser, error);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
static async bulkImport(req, res) {
|
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -109,24 +38,24 @@ module.exports = class Sync_jobsService {
|
|||||||
const bufferStream = new stream.PassThrough();
|
const bufferStream = new stream.PassThrough();
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8'));
|
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
bufferStream
|
bufferStream
|
||||||
.pipe(csv())
|
.pipe(csv())
|
||||||
.on('data', (row) => results.push(row))
|
.on('data', (data) => results.push(data))
|
||||||
.on('end', () => {
|
.on('end', async () => {
|
||||||
console.log('CSV results', results);
|
console.log('CSV results', results);
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
});
|
})
|
||||||
|
|
||||||
await Sync_jobsDBApi.bulkImport(results, {
|
await Sync_jobsDBApi.bulkImport(results, {
|
||||||
transaction,
|
transaction,
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
validate: true,
|
validate: true,
|
||||||
currentUser: req.currentUser,
|
currentUser: req.currentUser
|
||||||
});
|
});
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
@ -138,15 +67,16 @@ module.exports = class Sync_jobsService {
|
|||||||
|
|
||||||
static async update(data, id, currentUser) {
|
static async update(data, id, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sync_jobs = await Sync_jobsDBApi.findBy(
|
let sync_jobs = await Sync_jobsDBApi.findBy(
|
||||||
{ id },
|
{id},
|
||||||
{ transaction },
|
{transaction},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!sync_jobs) {
|
if (!sync_jobs) {
|
||||||
throw new ValidationError('sync_jobsNotFound');
|
throw new ValidationError(
|
||||||
|
'sync_jobsNotFound',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedSync_jobs = await Sync_jobsDBApi.update(
|
const updatedSync_jobs = await Sync_jobsDBApi.update(
|
||||||
@ -160,11 +90,12 @@ module.exports = class Sync_jobsService {
|
|||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
return updatedSync_jobs;
|
return updatedSync_jobs;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
static async deleteByIds(ids, currentUser) {
|
static async deleteByIds(ids, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
@ -200,4 +131,8 @@ module.exports = class Sync_jobsService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, {useEffect, useRef} from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useState } from 'react'
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
value: number;
|
|
||||||
duration?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AnimatedCount({ value, duration = 700 }: Props) {
|
|
||||||
const [displayValue, setDisplayValue] = React.useState(0);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
let frameId = 0;
|
|
||||||
const startTime = performance.now();
|
|
||||||
|
|
||||||
const tick = (currentTime: number) => {
|
|
||||||
const progress = Math.min((currentTime - startTime) / duration, 1);
|
|
||||||
const easedProgress = 1 - Math.pow(1 - progress, 3);
|
|
||||||
setDisplayValue(Math.round(value * easedProgress));
|
|
||||||
|
|
||||||
if (progress < 1) {
|
|
||||||
frameId = requestAnimationFrame(tick);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setDisplayValue(0);
|
|
||||||
frameId = requestAnimationFrame(tick);
|
|
||||||
|
|
||||||
return () => cancelAnimationFrame(frameId);
|
|
||||||
}, [duration, value]);
|
|
||||||
|
|
||||||
return <>{new Intl.NumberFormat().format(displayValue)}</>;
|
|
||||||
}
|
|
||||||
@ -1,212 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import AnimatedCount from './AnimatedCount';
|
|
||||||
import {
|
|
||||||
GameplayRoundCard,
|
|
||||||
isArtistCard,
|
|
||||||
isBeatmapCard,
|
|
||||||
isMapperCard,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
slot: 'a' | 'b';
|
|
||||||
card: GameplayRoundCard;
|
|
||||||
onPick?: (slot: 'a' | 'b') => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
selected?: boolean;
|
|
||||||
compact?: boolean;
|
|
||||||
reveal?: {
|
|
||||||
show: boolean;
|
|
||||||
value: number;
|
|
||||||
valueSuffix: string;
|
|
||||||
isWinner: boolean;
|
|
||||||
isWrongSelection: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBackgroundImage = (card: GameplayRoundCard) => {
|
|
||||||
if (isBeatmapCard(card)) {
|
|
||||||
return card.backgroundImageUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMapperCard(card)) {
|
|
||||||
return card.bannerUrl || card.fallbackVisual;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isArtistCard(card)) {
|
|
||||||
return card.imageUrl || card.fallbackVisual;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusClasses = ({
|
|
||||||
selected,
|
|
||||||
reveal,
|
|
||||||
}: Pick<Props, 'selected' | 'reveal'>) => {
|
|
||||||
if (reveal?.show && reveal.isWinner) {
|
|
||||||
return 'border-emerald-500/80 ring-2 ring-emerald-500/60';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reveal?.show && reveal.isWrongSelection) {
|
|
||||||
return 'border-red-500/80 ring-2 ring-red-500/60';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selected) {
|
|
||||||
return 'border-[#ff66aa]/80 ring-2 ring-[#ff66aa]/50';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'border-white/10 hover:border-white/30';
|
|
||||||
};
|
|
||||||
|
|
||||||
const AvatarFallback = ({ label }: { label: string }) => (
|
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-full border border-white/20 bg-white/10 text-lg font-semibold text-white shadow-sm shadow-black/30">
|
|
||||||
{label.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const AvatarImage = ({ src, alt }: { src?: string | null; alt: string }) => {
|
|
||||||
if (!src) {
|
|
||||||
return <AvatarFallback label={alt} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-label={alt}
|
|
||||||
className="h-16 w-16 rounded-full border border-white/30 bg-cover bg-center shadow-sm shadow-black/30"
|
|
||||||
role="img"
|
|
||||||
style={{ backgroundImage: `url(${src})` }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function GuessCard({
|
|
||||||
slot,
|
|
||||||
card,
|
|
||||||
onPick,
|
|
||||||
disabled = false,
|
|
||||||
selected = false,
|
|
||||||
compact = false,
|
|
||||||
reveal,
|
|
||||||
}: Props) {
|
|
||||||
const backgroundImage = getBackgroundImage(card);
|
|
||||||
const wrapperClasses = [
|
|
||||||
'group relative w-full overflow-hidden rounded-3xl border bg-[#131722] text-left text-white transition duration-150 ease-out',
|
|
||||||
compact ? 'min-h-[280px]' : 'min-h-[360px] md:min-h-[520px]',
|
|
||||||
disabled || !onPick ? 'cursor-default' : 'cursor-pointer',
|
|
||||||
getStatusClasses({ selected, reveal }),
|
|
||||||
].join(' ');
|
|
||||||
|
|
||||||
const body = (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-cover bg-center"
|
|
||||||
style={
|
|
||||||
backgroundImage
|
|
||||||
? {
|
|
||||||
backgroundImage: `url(${backgroundImage})`,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-black/25 via-black/45 to-black/90" />
|
|
||||||
<div className="absolute left-4 top-4 z-10 rounded-full border border-white/20 bg-black/40 px-3 py-1 text-xs font-semibold uppercase tracking-[0.22em] text-white/85 backdrop-blur-sm">
|
|
||||||
{slot}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex h-full flex-col justify-between p-5 md:p-6">
|
|
||||||
<div />
|
|
||||||
|
|
||||||
{isBeatmapCard(card) && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="rounded-full bg-white/10 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] text-white/75">
|
|
||||||
{card.status}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full bg-black/30 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] text-white/65">
|
|
||||||
{card.difficultyName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="max-w-[18ch] text-2xl font-semibold leading-tight md:text-4xl">
|
|
||||||
{card.title}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm text-white/78 md:text-base">
|
|
||||||
{card.artistName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 p-3 backdrop-blur-sm">
|
|
||||||
<AvatarImage alt={card.mapperName} src={card.mapperAvatarUrl} />
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/55">
|
|
||||||
Mapped by
|
|
||||||
</p>
|
|
||||||
<p className="text-lg font-medium text-white">{card.mapperName}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isMapperCard(card) && (
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-end gap-5 pb-3 text-center md:justify-center md:pb-0">
|
|
||||||
<AvatarImage alt={card.name} src={card.avatarUrl} />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/55">
|
|
||||||
Featured mapper
|
|
||||||
</p>
|
|
||||||
<p className="text-3xl font-semibold leading-tight md:text-5xl">
|
|
||||||
{card.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isArtistCard(card) && (
|
|
||||||
<div className="flex flex-1 items-end pb-3 md:items-center md:justify-center md:pb-0">
|
|
||||||
<div className="w-full rounded-[28px] border border-white/10 bg-black/25 p-5 text-center backdrop-blur-sm">
|
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/55">
|
|
||||||
Featured artist
|
|
||||||
</p>
|
|
||||||
<p className="mt-3 text-3xl font-semibold leading-tight md:text-5xl">
|
|
||||||
{card.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="pt-4">
|
|
||||||
{reveal?.show ? (
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 backdrop-blur-sm">
|
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/55">
|
|
||||||
Revealed
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-3xl font-semibold md:text-4xl">
|
|
||||||
<AnimatedCount value={reveal.value} />
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-white/70">{reveal.valueSuffix}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-white/78 backdrop-blur-sm">
|
|
||||||
Pick the card you think hides the higher value.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!onPick) {
|
|
||||||
return <div className={wrapperClasses}>{body}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
aria-label={`Choose ${slot.toUpperCase()}`}
|
|
||||||
className={wrapperClasses}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => onPick(slot)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{body}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
export type ModeSelection =
|
|
||||||
| 'beatmap_only'
|
|
||||||
| 'mapper_only'
|
|
||||||
| 'artist_only'
|
|
||||||
| 'mixed';
|
|
||||||
|
|
||||||
export type TimerProfile = 'normal' | 'insane';
|
|
||||||
|
|
||||||
export type GameplayBeatmapCard = {
|
|
||||||
title: string;
|
|
||||||
artistName: string;
|
|
||||||
mapperName: string;
|
|
||||||
mapperAvatarUrl?: string | null;
|
|
||||||
difficultyName: string;
|
|
||||||
status: string;
|
|
||||||
backgroundImageUrl?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GameplayMapperCard = {
|
|
||||||
name: string;
|
|
||||||
avatarUrl?: string | null;
|
|
||||||
bannerUrl?: string | null;
|
|
||||||
fallbackVisual?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GameplayArtistCard = {
|
|
||||||
name: string;
|
|
||||||
imageUrl?: string | null;
|
|
||||||
fallbackVisual?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GameplayRoundCard =
|
|
||||||
| GameplayBeatmapCard
|
|
||||||
| GameplayMapperCard
|
|
||||||
| GameplayArtistCard;
|
|
||||||
|
|
||||||
export type GameplayReveal = {
|
|
||||||
roundMode: 'beatmap' | 'mapper' | 'artist';
|
|
||||||
modeLabel: string;
|
|
||||||
valueSuffix: string;
|
|
||||||
correctChoice: 'a' | 'b';
|
|
||||||
winningChoice: 'a' | 'b';
|
|
||||||
playerChoice: 'a' | 'b' | 'none';
|
|
||||||
isCorrect: boolean;
|
|
||||||
loseReason: 'wrong' | 'timeout' | 'none';
|
|
||||||
values: {
|
|
||||||
a: number;
|
|
||||||
b: number;
|
|
||||||
};
|
|
||||||
answeredAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GameplayRound = {
|
|
||||||
id: string;
|
|
||||||
roundNumber: number;
|
|
||||||
timeLimitMs: number;
|
|
||||||
presentedAt: string;
|
|
||||||
expiresAt: string;
|
|
||||||
cards: {
|
|
||||||
a: GameplayRoundCard;
|
|
||||||
b: GameplayRoundCard;
|
|
||||||
};
|
|
||||||
reveal?: GameplayReveal;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GameplaySession = {
|
|
||||||
id: string;
|
|
||||||
status: 'active' | 'finished' | 'abandoned';
|
|
||||||
modeSelection: ModeSelection;
|
|
||||||
timerProfile: TimerProfile;
|
|
||||||
sessionToken: string;
|
|
||||||
startingLives: number;
|
|
||||||
livesRemaining: number;
|
|
||||||
streak: number;
|
|
||||||
bestStreak: number;
|
|
||||||
startedAt: string;
|
|
||||||
endedAt?: string | null;
|
|
||||||
roundsPlayed: number;
|
|
||||||
correctGuesses: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HistorySession = GameplaySession & {
|
|
||||||
leaderboardEntry?: {
|
|
||||||
score: number;
|
|
||||||
bestStreak: number;
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GameplayLeaderboardEntry = {
|
|
||||||
id: string;
|
|
||||||
score: number;
|
|
||||||
bestStreak: number;
|
|
||||||
roundsSurvived: number;
|
|
||||||
achievedAt: string;
|
|
||||||
user: {
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isBeatmapCard = (
|
|
||||||
card: GameplayRoundCard,
|
|
||||||
): card is GameplayBeatmapCard => 'title' in card;
|
|
||||||
|
|
||||||
export const isMapperCard = (
|
|
||||||
card: GameplayRoundCard,
|
|
||||||
): card is GameplayMapperCard => 'bannerUrl' in card;
|
|
||||||
|
|
||||||
export const isArtistCard = (
|
|
||||||
card: GameplayRoundCard,
|
|
||||||
): card is GameplayArtistCard => !isBeatmapCard(card) && !isMapperCard(card);
|
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import React, { ReactNode, useEffect, useState } from 'react'
|
import React, { ReactNode, useEffect } from 'react'
|
||||||
|
import { useState } from 'react'
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -2,18 +2,6 @@ import * as icon from '@mdi/js';
|
|||||||
import { MenuAsideItem } from './interfaces'
|
import { MenuAsideItem } from './interfaces'
|
||||||
|
|
||||||
const menuAside: MenuAsideItem[] = [
|
const menuAside: MenuAsideItem[] = [
|
||||||
{
|
|
||||||
href: '/play',
|
|
||||||
icon: 'mdiGamepadSquareOutline' in icon ? icon['mdiGamepadSquareOutline' as keyof typeof icon] : icon.mdiTable,
|
|
||||||
label: 'Play',
|
|
||||||
permissions: 'READ_GAME_SESSIONS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/play/history',
|
|
||||||
icon: icon.mdiHistory ?? icon.mdiTable,
|
|
||||||
label: 'Run history',
|
|
||||||
permissions: 'READ_GAME_SESSIONS'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: '/dashboard',
|
href: '/dashboard',
|
||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
|
|||||||
@ -1,264 +1,166 @@
|
|||||||
import {
|
|
||||||
mdiDatabaseOutline,
|
import React, { useEffect, useState } from 'react';
|
||||||
mdiGamepadSquareOutline,
|
import type { ReactElement } from 'react';
|
||||||
mdiPlayCircleOutline,
|
|
||||||
mdiShieldCheckOutline,
|
|
||||||
mdiTimerOutline,
|
|
||||||
mdiViewDashboardOutline,
|
|
||||||
} from '@mdi/js';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import React from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import BaseIcon from '../components/BaseIcon';
|
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
import { getPageTitle } from '../config';
|
import SectionFullScreen from '../components/SectionFullScreen';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
|
import BaseDivider from '../components/BaseDivider';
|
||||||
|
import BaseButtons from '../components/BaseButtons';
|
||||||
|
import { getPageTitle } from '../config';
|
||||||
|
import { useAppSelector } from '../stores/hooks';
|
||||||
|
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||||
|
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||||
|
|
||||||
const heroCards = [
|
|
||||||
{
|
|
||||||
slot: 'A',
|
|
||||||
title: 'Blue Zenith',
|
|
||||||
subtitle: 'xi · mapped by Monstrata',
|
|
||||||
image:
|
|
||||||
'https://assets.ppy.sh/beatmaps/874923/covers/raw.jpg',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slot: 'B',
|
|
||||||
title: 'Kyouran Hey Kids!!',
|
|
||||||
subtitle: 'THE ORAL CIGARETTES · mapped by Sotarks',
|
|
||||||
image:
|
|
||||||
'https://assets.ppy.sh/beatmaps/102120/covers/raw.jpg',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const featureCards = [
|
|
||||||
{
|
|
||||||
icon: mdiShieldCheckOutline,
|
|
||||||
title: 'Server-hidden answers',
|
|
||||||
body: 'Playcounts, mapper totals, artist totals, and the mixed-mode selection stay hidden until the answer is locked in.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: mdiTimerOutline,
|
|
||||||
title: 'Five to eight second rounds',
|
|
||||||
body: 'Each guess resolves immediately. A timeout costs one life and the next card pair rolls in without loading screens.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: mdiDatabaseOutline,
|
|
||||||
title: 'Ranked + loved only',
|
|
||||||
body: 'The pool is scoped to osu!standard with precomputed mapper and artist aggregates for fast constant-time round evaluation.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const modeCards = [
|
|
||||||
{
|
|
||||||
label: 'Beatmap',
|
|
||||||
helper: 'Compare raw beatmap playcount.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Mapper',
|
|
||||||
helper: 'Show mapper avatar + banner only, then reveal total mapper plays.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Artist',
|
|
||||||
helper: 'Show artist image + name only, then reveal total artist plays.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Mixed',
|
|
||||||
helper: 'The server chooses the round type each time and reveals it only after the guess.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Starter() {
|
export default function Starter() {
|
||||||
|
const [illustrationImage, setIllustrationImage] = useState({
|
||||||
|
src: undefined,
|
||||||
|
photographer: undefined,
|
||||||
|
photographer_url: undefined,
|
||||||
|
})
|
||||||
|
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||||
|
const [contentType, setContentType] = useState('image');
|
||||||
|
const [contentPosition, setContentPosition] = useState('right');
|
||||||
|
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||||
|
|
||||||
|
const title = 'Osu Higher Lower'
|
||||||
|
|
||||||
|
// Fetch Pexels image/video
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
const image = await getPexelsImage();
|
||||||
|
const video = await getPexelsVideo();
|
||||||
|
setIllustrationImage(image);
|
||||||
|
setIllustrationVideo(video);
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const imageBlock = (image) => (
|
||||||
|
<div
|
||||||
|
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||||
|
style={{
|
||||||
|
backgroundImage: `${
|
||||||
|
image
|
||||||
|
? `url(${image?.src?.original})`
|
||||||
|
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||||
|
}`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'left center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||||
|
<a
|
||||||
|
className='text-[8px]'
|
||||||
|
href={image?.photographer_url}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
>
|
||||||
|
Photo by {image?.photographer} on Pexels
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const videoBlock = (video) => {
|
||||||
|
if (video?.video_files?.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||||
|
<video
|
||||||
|
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
>
|
||||||
|
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||||
|
<a
|
||||||
|
className='text-[8px]'
|
||||||
|
href={video?.user?.url}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
>
|
||||||
|
Video by {video.user.name} on Pexels
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
|
style={
|
||||||
|
contentPosition === 'background'
|
||||||
|
? {
|
||||||
|
backgroundImage: `${
|
||||||
|
illustrationImage
|
||||||
|
? `url(${illustrationImage.src?.original})`
|
||||||
|
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||||
|
}`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'left center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Osu Higher Lower')}</title>
|
<title>{getPageTitle('Starter Page')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<div className="min-h-screen bg-[#0b0d12] text-white">
|
<SectionFullScreen bg='violet'>
|
||||||
<div className="mx-auto flex min-h-screen max-w-7xl flex-col px-6 py-6 lg:px-10">
|
<div
|
||||||
<header className="flex items-center justify-between gap-4 border-b border-white/10 pb-5">
|
className={`flex ${
|
||||||
<Link className="flex items-center gap-3" href="/">
|
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||||
<span className="flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/5">
|
} min-h-screen w-full`}
|
||||||
<BaseIcon className="text-[#ff66aa]" path={mdiGamepadSquareOutline} size={20} />
|
>
|
||||||
</span>
|
{contentType === 'image' && contentPosition !== 'background'
|
||||||
<div>
|
? imageBlock(illustrationImage)
|
||||||
<p className="text-xs uppercase tracking-[0.22em] text-white/45">
|
: null}
|
||||||
Osu Higher Lower
|
{contentType === 'video' && contentPosition !== 'background'
|
||||||
</p>
|
? videoBlock(illustrationVideo)
|
||||||
<p className="text-sm text-white/70">Fast browser guessing game</p>
|
: null}
|
||||||
</div>
|
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||||
</Link>
|
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||||
|
<CardBoxComponentTitle title="Welcome to your Osu Higher Lower app!"/>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<BaseButton color="whiteDark" href="/login" label="Login" />
|
<div className="space-y-3">
|
||||||
<BaseButton
|
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||||
color="whiteDark"
|
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||||
href="/dashboard"
|
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||||
icon={mdiViewDashboardOutline}
|
|
||||||
label="Admin interface"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
<BaseButtons>
|
||||||
|
<BaseButton
|
||||||
|
href='/login'
|
||||||
|
label='Login'
|
||||||
|
color='info'
|
||||||
|
className='w-full'
|
||||||
|
/>
|
||||||
|
|
||||||
<main className="flex-1 py-10 lg:py-14">
|
</BaseButtons>
|
||||||
<section className="grid items-center gap-10 lg:grid-cols-[1.05fr_0.95fr]">
|
</CardBox>
|
||||||
<div>
|
|
||||||
<p className="text-sm uppercase tracking-[0.28em] text-white/45">
|
|
||||||
osu!standard · ranked + loved
|
|
||||||
</p>
|
|
||||||
<h1 className="mt-4 max-w-3xl text-5xl font-semibold tracking-tight text-white md:text-6xl">
|
|
||||||
Guess the higher hidden osu! stat and survive the run.
|
|
||||||
</h1>
|
|
||||||
<p className="mt-5 max-w-2xl text-lg leading-8 text-white/68">
|
|
||||||
Two cards. Two lives. Five to eight seconds. Beatmap playcount, mapper
|
|
||||||
total plays, artist total plays — all decided server-side for a fast,
|
|
||||||
minimal, anti-cheat-first competitive loop.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
|
||||||
<BaseButton
|
|
||||||
color="info"
|
|
||||||
href="/play"
|
|
||||||
icon={mdiPlayCircleOutline}
|
|
||||||
label="Open the arena"
|
|
||||||
/>
|
|
||||||
<BaseButton
|
|
||||||
color="whiteDark"
|
|
||||||
href="/dashboard"
|
|
||||||
icon={mdiViewDashboardOutline}
|
|
||||||
label="Admin interface"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-10 grid gap-4 sm:grid-cols-3">
|
|
||||||
<div className="rounded-[28px] border border-white/10 bg-white/5 px-4 py-4">
|
|
||||||
<p className="text-3xl font-semibold text-white">2</p>
|
|
||||||
<p className="mt-1 text-sm text-white/55">lives per run</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[28px] border border-white/10 bg-white/5 px-4 py-4">
|
|
||||||
<p className="text-3xl font-semibold text-white">8s / 5s</p>
|
|
||||||
<p className="mt-1 text-sm text-white/55">normal and insane timers</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[28px] border border-white/10 bg-white/5 px-4 py-4">
|
|
||||||
<p className="text-3xl font-semibold text-white">O(1)</p>
|
|
||||||
<p className="mt-1 text-sm text-white/55">gameplay queries from precomputed totals</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
|
||||||
{heroCards.map((card) => (
|
|
||||||
<div
|
|
||||||
className="relative min-h-[360px] overflow-hidden rounded-[32px] border border-white/10 bg-[#131722]"
|
|
||||||
key={card.slot}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-cover bg-center"
|
|
||||||
style={{ backgroundImage: `url(${card.image})` }}
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-black/25 via-black/40 to-black/90" />
|
|
||||||
<div className="relative flex h-full flex-col justify-between p-5">
|
|
||||||
<div className="rounded-full border border-white/15 bg-black/35 px-3 py-1 text-xs font-semibold uppercase tracking-[0.22em] text-white/80">
|
|
||||||
{card.slot}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="max-w-[12ch] text-3xl font-semibold leading-tight text-white">
|
|
||||||
{card.title}
|
|
||||||
</p>
|
|
||||||
<p className="mt-3 text-sm leading-6 text-white/72">
|
|
||||||
{card.subtitle}
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-white/68 backdrop-blur-sm">
|
|
||||||
Hidden values stay off the client until the guess is submitted.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mt-12 grid gap-4 lg:grid-cols-3">
|
|
||||||
{featureCards.map((feature) => (
|
|
||||||
<CardBox
|
|
||||||
className="border border-white/10 bg-[#10141d] text-white"
|
|
||||||
key={feature.title}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<span className="mt-1 flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/5">
|
|
||||||
<BaseIcon className="text-white/75" path={feature.icon} size={18} />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-lg font-semibold text-white">{feature.title}</p>
|
|
||||||
<p className="mt-2 text-sm leading-7 text-white/62">{feature.body}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mt-12 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
|
|
||||||
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
|
||||||
<p className="text-sm uppercase tracking-[0.22em] text-white/45">
|
|
||||||
Round formats
|
|
||||||
</p>
|
|
||||||
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
|
||||||
{modeCards.map((mode) => (
|
|
||||||
<div
|
|
||||||
className="rounded-[28px] border border-white/10 bg-white/5 px-4 py-4"
|
|
||||||
key={mode.label}
|
|
||||||
>
|
|
||||||
<p className="text-xl font-semibold text-white">{mode.label}</p>
|
|
||||||
<p className="mt-2 text-sm leading-7 text-white/62">{mode.helper}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
|
|
||||||
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
|
||||||
<p className="text-sm uppercase tracking-[0.22em] text-white/45">
|
|
||||||
Included admin shell
|
|
||||||
</p>
|
|
||||||
<p className="mt-4 text-2xl font-semibold text-white">
|
|
||||||
Manage beatmaps, aggregates, sync jobs, sessions, and leaderboards from the built-in admin side.
|
|
||||||
</p>
|
|
||||||
<p className="mt-4 text-sm leading-7 text-white/62">
|
|
||||||
The public page keeps the login link intact and gives you a direct path to the admin interface for data ops and QA.
|
|
||||||
</p>
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
|
||||||
<BaseButton
|
|
||||||
color="whiteDark"
|
|
||||||
href="/login"
|
|
||||||
label="Login"
|
|
||||||
/>
|
|
||||||
<BaseButton
|
|
||||||
color="info"
|
|
||||||
href="/dashboard"
|
|
||||||
icon={mdiViewDashboardOutline}
|
|
||||||
label="Admin interface"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer className="flex flex-col gap-4 border-t border-white/10 pt-5 text-sm text-white/45 md:flex-row md:items-center md:justify-between">
|
|
||||||
<p>© 2026 Osu Higher Lower. Clean rounds, hidden answers, no extra fluff.</p>
|
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
|
||||||
<Link href="/privacy-policy">Privacy Policy</Link>
|
|
||||||
<Link href="/login">Login</Link>
|
|
||||||
<Link href="/dashboard">Admin interface</Link>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</SectionFullScreen>
|
||||||
|
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||||
|
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||||
|
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,298 +0,0 @@
|
|||||||
import {
|
|
||||||
mdiChartBoxOutline,
|
|
||||||
mdiGamepadSquareOutline,
|
|
||||||
mdiHistory,
|
|
||||||
mdiPlayCircleOutline,
|
|
||||||
mdiShieldCheckOutline,
|
|
||||||
mdiTimerOutline,
|
|
||||||
} from '@mdi/js';
|
|
||||||
import Head from 'next/head';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import React, { ReactElement } from 'react';
|
|
||||||
import axios from 'axios';
|
|
||||||
import BaseButton from '../../components/BaseButton';
|
|
||||||
import BaseIcon from '../../components/BaseIcon';
|
|
||||||
import CardBox from '../../components/CardBox';
|
|
||||||
import {
|
|
||||||
GameplayLeaderboardEntry,
|
|
||||||
HistorySession,
|
|
||||||
} 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: HistorySession['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 statusTone: Record<HistorySession['status'], string> = {
|
|
||||||
active: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-100',
|
|
||||||
finished: 'border-white/10 bg-white/5 text-white/70',
|
|
||||||
abandoned: 'border-yellow-500/30 bg-yellow-500/10 text-yellow-100',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PlayHistoryPage() {
|
|
||||||
const [sessions, setSessions] = React.useState<HistorySession[]>([]);
|
|
||||||
const [leaderboard, setLeaderboard] = React.useState<GameplayLeaderboardEntry[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
|
||||||
const [errorMessage, setErrorMessage] = React.useState('');
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const loadHistory = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setErrorMessage('');
|
|
||||||
const { data } = await axios.get('/gameplay/sessions');
|
|
||||||
|
|
||||||
if (!isMounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSessions(Array.isArray(data?.sessions) ? data.sessions : []);
|
|
||||||
setLeaderboard(Array.isArray(data?.leaderboard) ? data.leaderboard : []);
|
|
||||||
} catch (error: any) {
|
|
||||||
if (!isMounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrorMessage(error?.response?.data || 'Could not load your run history.');
|
|
||||||
} finally {
|
|
||||||
if (isMounted) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void loadHistory();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{getPageTitle('Run history')}</title>
|
|
||||||
</Head>
|
|
||||||
|
|
||||||
<SectionMain>
|
|
||||||
<SectionTitleLineWithButton icon={mdiHistory} main title="Run history">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<BaseButton
|
|
||||||
color="info"
|
|
||||||
href="/play"
|
|
||||||
icon={mdiPlayCircleOutline}
|
|
||||||
label="Back to play"
|
|
||||||
/>
|
|
||||||
</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}
|
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[1.65fr_1fr]">
|
|
||||||
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm uppercase tracking-[0.22em] text-white/45">
|
|
||||||
Your recent sessions
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-2xl font-semibold text-white">
|
|
||||||
Review finished runs or resume the active one.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 space-y-4">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-8 text-center text-sm text-white/55">
|
|
||||||
Loading your sessions...
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isLoading && !sessions.length ? (
|
|
||||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-10 text-center text-white/55">
|
|
||||||
You have not started a run yet. Head back to the arena and launch your
|
|
||||||
first session.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isLoading
|
|
||||||
? sessions.map((session) => (
|
|
||||||
<Link
|
|
||||||
className="block rounded-[28px] border border-white/10 bg-white/5 px-5 py-5 transition hover:border-white/20"
|
|
||||||
href={
|
|
||||||
session.status === 'active'
|
|
||||||
? '/play'
|
|
||||||
: `/play/sessions/${session.id}`
|
|
||||||
}
|
|
||||||
key={session.id}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-2.5">
|
|
||||||
<span
|
|
||||||
className={`rounded-full border px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] ${statusTone[session.status]}`}
|
|
||||||
>
|
|
||||||
{session.status === 'active' ? 'In progress' : session.status}
|
|
||||||
</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">
|
|
||||||
{formatModeSelection(session.modeSelection)}
|
|
||||||
</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">
|
|
||||||
{session.timerProfile === 'insane' ? 'Insane · 5s' : 'Normal · 8s'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-semibold text-white">
|
|
||||||
{session.correctGuesses} correct guesses
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm text-white/55">
|
|
||||||
{formatSessionDate(session.endedAt || session.startedAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3 lg:w-[390px]">
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
|
||||||
<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-xl font-semibold text-white">
|
|
||||||
{session.bestStreak}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
|
||||||
<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">
|
|
||||||
Rounds played
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xl font-semibold text-white">
|
|
||||||
{session.roundsPlayed}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
|
||||||
<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-xl font-semibold text-white">
|
|
||||||
{session.leaderboardEntry?.score ?? session.correctGuesses}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
|
||||||
<p className="text-sm uppercase tracking-[0.22em] text-white/45">
|
|
||||||
Top all-time scores
|
|
||||||
</p>
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{leaderboard.length ? (
|
|
||||||
leaderboard.map((entry, index) => (
|
|
||||||
<div
|
|
||||||
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4"
|
|
||||||
key={entry.id}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-white/55">#{index + 1}</p>
|
|
||||||
<p className="mt-1 text-base font-medium text-white">
|
|
||||||
{entry.user.name}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm text-white/45">
|
|
||||||
{formatSessionDate(entry.achievedAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-2xl font-semibold text-white">{entry.score}</p>
|
|
||||||
<p className="text-xs uppercase tracking-[0.16em] text-white/45">
|
|
||||||
correct
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-6 text-sm text-white/55">
|
|
||||||
No leaderboard entries yet.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
|
|
||||||
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
|
||||||
<p className="text-sm uppercase tracking-[0.22em] text-white/45">
|
|
||||||
Need another run?
|
|
||||||
</p>
|
|
||||||
<p className="mt-3 text-base leading-7 text-white/65">
|
|
||||||
Start a fresh session from the play screen. Active runs are preserved on the
|
|
||||||
server until you finish or replace them.
|
|
||||||
</p>
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
|
||||||
<BaseButton
|
|
||||||
color="info"
|
|
||||||
href="/play"
|
|
||||||
icon={mdiGamepadSquareOutline}
|
|
||||||
label="Open arena"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PlayHistoryPage.getLayout = function getLayout(page: ReactElement) {
|
|
||||||
return <LayoutAuthenticated permission="READ_GAME_SESSIONS">{page}</LayoutAuthenticated>;
|
|
||||||
};
|
|
||||||
@ -1,768 +0,0 @@
|
|||||||
import {
|
|
||||||
mdiChartBoxOutline,
|
|
||||||
mdiFlash,
|
|
||||||
mdiGamepadSquareOutline,
|
|
||||||
mdiHeart,
|
|
||||||
mdiHeartOutline,
|
|
||||||
mdiHistory,
|
|
||||||
mdiRefresh,
|
|
||||||
mdiShieldCheckOutline,
|
|
||||||
mdiTimerOutline,
|
|
||||||
} from '@mdi/js';
|
|
||||||
import Head from 'next/head';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import React, { ReactElement } from 'react';
|
|
||||||
import axios from 'axios';
|
|
||||||
import BaseButton from '../../components/BaseButton';
|
|
||||||
import BaseIcon from '../../components/BaseIcon';
|
|
||||||
import CardBox from '../../components/CardBox';
|
|
||||||
import GuessCard from '../../components/OsuHigherLower/GuessCard';
|
|
||||||
import {
|
|
||||||
GameplayLeaderboardEntry,
|
|
||||||
GameplayRound,
|
|
||||||
GameplaySession,
|
|
||||||
HistorySession,
|
|
||||||
ModeSelection,
|
|
||||||
TimerProfile,
|
|
||||||
} from '../../components/OsuHigherLower/types';
|
|
||||||
import SectionMain from '../../components/SectionMain';
|
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
|
||||||
import { getPageTitle } from '../../config';
|
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
|
||||||
|
|
||||||
const ADVANCE_DELAY_MS = 1050;
|
|
||||||
|
|
||||||
const modeOptions: Array<{
|
|
||||||
value: ModeSelection;
|
|
||||||
label: string;
|
|
||||||
helper: string;
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
value: 'mixed',
|
|
||||||
label: 'Mixed',
|
|
||||||
helper: 'Server shuffles beatmap, mapper, and artist rounds. The label stays hidden until reveal.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'beatmap_only',
|
|
||||||
label: 'Beatmap',
|
|
||||||
helper: 'Classic higher-lower using ranked or loved osu!standard beatmap playcount.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'mapper_only',
|
|
||||||
label: 'Mapper',
|
|
||||||
helper: 'Compare total plays accumulated across each mapper’s ranked and loved maps.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'artist_only',
|
|
||||||
label: 'Artist',
|
|
||||||
helper: 'Compare total plays accumulated across each artist’s ranked and loved maps.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const timerOptions: Array<{
|
|
||||||
value: TimerProfile;
|
|
||||||
label: string;
|
|
||||||
helper: string;
|
|
||||||
icon: string;
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
value: 'normal',
|
|
||||||
label: 'Normal · 8s',
|
|
||||||
helper: 'A quick pace that still gives you time to read each card carefully.',
|
|
||||||
icon: mdiTimerOutline,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'insane',
|
|
||||||
label: 'Insane · 5s',
|
|
||||||
helper: 'Very fast rounds with no breathing room between reveals.',
|
|
||||||
icon: mdiFlash,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 getResultMessage = (round?: GameplayRound | null) => {
|
|
||||||
if (!round?.reveal) {
|
|
||||||
return 'Mode hidden until you answer.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (round.reveal.loseReason === 'timeout') {
|
|
||||||
return `${round.reveal.modeLabel} revealed — time ran out.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return round.reveal.isCorrect
|
|
||||||
? `${round.reveal.modeLabel} revealed — correct guess.`
|
|
||||||
: `${round.reveal.modeLabel} revealed — wrong guess.`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PlayPage() {
|
|
||||||
const [modeSelection, setModeSelection] = React.useState<ModeSelection>('mixed');
|
|
||||||
const [timerProfile, setTimerProfile] = React.useState<TimerProfile>('normal');
|
|
||||||
const [session, setSession] = React.useState<GameplaySession | null>(null);
|
|
||||||
const [currentRound, setCurrentRound] = React.useState<GameplayRound | null>(null);
|
|
||||||
const [revealedRound, setRevealedRound] = React.useState<GameplayRound | null>(null);
|
|
||||||
const [history, setHistory] = React.useState<HistorySession[]>([]);
|
|
||||||
const [leaderboard, setLeaderboard] = React.useState<GameplayLeaderboardEntry[]>([]);
|
|
||||||
const [isLoadingState, setIsLoadingState] = React.useState(true);
|
|
||||||
const [isStarting, setIsStarting] = React.useState(false);
|
|
||||||
const [isSubmittingChoice, setIsSubmittingChoice] = React.useState(false);
|
|
||||||
const [errorMessage, setErrorMessage] = React.useState('');
|
|
||||||
const [remainingMs, setRemainingMs] = React.useState(0);
|
|
||||||
const advanceTimerRef = React.useRef<number | null>(null);
|
|
||||||
const timeoutTriggeredRef = React.useRef(false);
|
|
||||||
|
|
||||||
const loadHistory = React.useCallback(async () => {
|
|
||||||
const { data } = await axios.get('/gameplay/sessions');
|
|
||||||
setHistory(Array.isArray(data?.sessions) ? data.sessions : []);
|
|
||||||
setLeaderboard(Array.isArray(data?.leaderboard) ? data.leaderboard : []);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const queueNextRound = React.useCallback(
|
|
||||||
(nextRound: GameplayRound | null, nextSession: GameplaySession) => {
|
|
||||||
if (advanceTimerRef.current) {
|
|
||||||
window.clearTimeout(advanceTimerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nextRound) {
|
|
||||||
void loadHistory();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
advanceTimerRef.current = window.setTimeout(() => {
|
|
||||||
setSession(nextSession);
|
|
||||||
setRevealedRound(null);
|
|
||||||
setCurrentRound(nextRound);
|
|
||||||
setRemainingMs(nextRound.timeLimitMs);
|
|
||||||
timeoutTriggeredRef.current = false;
|
|
||||||
}, ADVANCE_DELAY_MS);
|
|
||||||
},
|
|
||||||
[loadHistory],
|
|
||||||
);
|
|
||||||
|
|
||||||
const applyResolution = React.useCallback(
|
|
||||||
(payload: {
|
|
||||||
session: GameplaySession;
|
|
||||||
result: GameplayRound;
|
|
||||||
nextRound: GameplayRound | null;
|
|
||||||
}) => {
|
|
||||||
setSession(payload.session);
|
|
||||||
setCurrentRound(null);
|
|
||||||
setRevealedRound(payload.result);
|
|
||||||
queueNextRound(payload.nextRound, payload.session);
|
|
||||||
},
|
|
||||||
[queueNextRound],
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadActiveSession = React.useCallback(async () => {
|
|
||||||
const { data } = await axios.get('/gameplay/active');
|
|
||||||
|
|
||||||
if (!data?.activeSession) {
|
|
||||||
setSession(null);
|
|
||||||
setCurrentRound(null);
|
|
||||||
setRevealedRound(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.currentRound) {
|
|
||||||
setSession(data.session);
|
|
||||||
setCurrentRound(data.currentRound);
|
|
||||||
setRevealedRound(null);
|
|
||||||
setRemainingMs(data.currentRound.timeLimitMs);
|
|
||||||
timeoutTriggeredRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.result) {
|
|
||||||
applyResolution({
|
|
||||||
session: data.session,
|
|
||||||
result: data.result,
|
|
||||||
nextRound: data.nextRound ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [applyResolution]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const loadPage = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoadingState(true);
|
|
||||||
setErrorMessage('');
|
|
||||||
await Promise.all([loadHistory(), loadActiveSession()]);
|
|
||||||
} catch (error: any) {
|
|
||||||
if (!isMounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrorMessage(
|
|
||||||
error?.response?.data || 'Unable to load the game state right now.',
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (isMounted) {
|
|
||||||
setIsLoadingState(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void loadPage();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
if (advanceTimerRef.current) {
|
|
||||||
window.clearTimeout(advanceTimerRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [loadActiveSession, loadHistory]);
|
|
||||||
|
|
||||||
const submitChoice = React.useCallback(
|
|
||||||
async (choice: 'a' | 'b' | 'none') => {
|
|
||||||
if (!session || !currentRound || isSubmittingChoice) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsSubmittingChoice(true);
|
|
||||||
setErrorMessage('');
|
|
||||||
timeoutTriggeredRef.current = choice === 'none';
|
|
||||||
|
|
||||||
const { data } = await axios.post(
|
|
||||||
`/gameplay/sessions/${session.id}/rounds/${currentRound.id}/answer`,
|
|
||||||
{ choice },
|
|
||||||
);
|
|
||||||
|
|
||||||
applyResolution({
|
|
||||||
session: data.session,
|
|
||||||
result: data.result,
|
|
||||||
nextRound: data.nextRound ?? null,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
setErrorMessage(
|
|
||||||
error?.response?.data || 'Could not submit your guess. Please try again.',
|
|
||||||
);
|
|
||||||
await loadActiveSession();
|
|
||||||
} finally {
|
|
||||||
setIsSubmittingChoice(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[applyResolution, currentRound, isSubmittingChoice, loadActiveSession, session],
|
|
||||||
);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!currentRound) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresAt = new Date(currentRound.expiresAt).getTime();
|
|
||||||
|
|
||||||
const tick = () => {
|
|
||||||
const nextRemainingMs = Math.max(expiresAt - Date.now(), 0);
|
|
||||||
setRemainingMs(nextRemainingMs);
|
|
||||||
|
|
||||||
if (nextRemainingMs === 0 && !timeoutTriggeredRef.current) {
|
|
||||||
timeoutTriggeredRef.current = true;
|
|
||||||
void submitChoice('none');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
tick();
|
|
||||||
const timerId = window.setInterval(tick, 100);
|
|
||||||
|
|
||||||
return () => window.clearInterval(timerId);
|
|
||||||
}, [currentRound, submitChoice]);
|
|
||||||
|
|
||||||
const handleStartSession = async () => {
|
|
||||||
try {
|
|
||||||
setIsStarting(true);
|
|
||||||
setErrorMessage('');
|
|
||||||
setRevealedRound(null);
|
|
||||||
setCurrentRound(null);
|
|
||||||
|
|
||||||
const { data } = await axios.post('/gameplay/sessions/start', {
|
|
||||||
modeSelection,
|
|
||||||
timerProfile,
|
|
||||||
});
|
|
||||||
|
|
||||||
setSession(data.session);
|
|
||||||
setCurrentRound(data.currentRound);
|
|
||||||
setRemainingMs(data.currentRound?.timeLimitMs || 0);
|
|
||||||
timeoutTriggeredRef.current = false;
|
|
||||||
await loadHistory();
|
|
||||||
} catch (error: any) {
|
|
||||||
setErrorMessage(
|
|
||||||
error?.response?.data || 'Could not start a new session right now.',
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsStarting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeRound = currentRound || revealedRound;
|
|
||||||
const timerProgress = React.useMemo(() => {
|
|
||||||
if (!currentRound) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.max(
|
|
||||||
0,
|
|
||||||
Math.min((remainingMs / currentRound.timeLimitMs) * 100, 100),
|
|
||||||
);
|
|
||||||
}, [currentRound, remainingMs]);
|
|
||||||
|
|
||||||
const lives = session?.startingLives ?? 2;
|
|
||||||
const lobbyVisible = !session || (!currentRound && !revealedRound);
|
|
||||||
const gameOver = session?.status === 'finished' && Boolean(revealedRound);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{getPageTitle('Play')}</title>
|
|
||||||
</Head>
|
|
||||||
|
|
||||||
<SectionMain>
|
|
||||||
<SectionTitleLineWithButton
|
|
||||||
icon={mdiGamepadSquareOutline}
|
|
||||||
main
|
|
||||||
title="Play"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<BaseButton
|
|
||||||
color="whiteDark"
|
|
||||||
href="/play/history"
|
|
||||||
icon={mdiHistory}
|
|
||||||
label="Run history"
|
|
||||||
/>
|
|
||||||
</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}
|
|
||||||
|
|
||||||
{isLoadingState ? (
|
|
||||||
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
|
||||||
<div className="space-y-2 py-10 text-center">
|
|
||||||
<p className="text-sm uppercase tracking-[0.22em] text-white/45">
|
|
||||||
Loading arena
|
|
||||||
</p>
|
|
||||||
<p className="text-lg text-white/80">
|
|
||||||
Restoring your current run and recent leaderboard.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isLoadingState && session ? (
|
|
||||||
<div className="mb-6 grid gap-4 lg:grid-cols-[1.6fr_1fr]">
|
|
||||||
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<div className="rounded-full border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium uppercase tracking-[0.2em] text-white/60">
|
|
||||||
{formatModeSelection(session.modeSelection)}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-full border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium uppercase tracking-[0.2em] text-white/60">
|
|
||||||
{session.timerProfile === 'insane' ? 'Insane · 5s' : 'Normal · 8s'}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-full border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium uppercase tracking-[0.2em] text-white/60">
|
|
||||||
Round {activeRound?.roundNumber || session.roundsPlayed + 1}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<BaseIcon className="text-white/70" path={mdiHeart} size={18} />
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] uppercase tracking-[0.18em] text-white/45">
|
|
||||||
Lives
|
|
||||||
</p>
|
|
||||||
<div className="mt-1 flex items-center gap-1.5">
|
|
||||||
{Array.from({ length: lives }).map((_, index) => (
|
|
||||||
<BaseIcon
|
|
||||||
key={`life-${index}`}
|
|
||||||
className={
|
|
||||||
index < (session.livesRemaining || 0)
|
|
||||||
? 'text-[#ff668f]'
|
|
||||||
: 'text-white/20'
|
|
||||||
}
|
|
||||||
path={
|
|
||||||
index < (session.livesRemaining || 0)
|
|
||||||
? mdiHeart
|
|
||||||
: mdiHeartOutline
|
|
||||||
}
|
|
||||||
size={18}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<BaseIcon className="text-white/70" path={mdiShieldCheckOutline} size={18} />
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] uppercase tracking-[0.18em] text-white/45">
|
|
||||||
Current streak
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold text-white">
|
|
||||||
{session.streak}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<BaseIcon className="text-white/70" path={mdiChartBoxOutline} size={18} />
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] uppercase tracking-[0.18em] text-white/45">
|
|
||||||
Best streak
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold text-white">
|
|
||||||
{session.bestStreak}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
|
|
||||||
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] uppercase tracking-[0.18em] text-white/45">
|
|
||||||
Round timer
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-lg font-semibold text-white">
|
|
||||||
{currentRound
|
|
||||||
? `${Math.max(remainingMs / 1000, 0).toFixed(1)}s left`
|
|
||||||
: 'Resolving round'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<BaseIcon className="text-white/60" path={mdiTimerOutline} size={20} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full transition-[width] duration-100 ${
|
|
||||||
timerProgress > 45
|
|
||||||
? 'bg-emerald-400'
|
|
||||||
: timerProgress > 15
|
|
||||||
? 'bg-yellow-400'
|
|
||||||
: 'bg-red-400'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${timerProgress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-white/62">{getResultMessage(revealedRound)}</p>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isLoadingState && lobbyVisible ? (
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[1.5fr_1fr]">
|
|
||||||
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-white/45">
|
|
||||||
Quick start
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-white">
|
|
||||||
Minimal, fast, and anti-cheat.
|
|
||||||
</h2>
|
|
||||||
<p className="mt-3 max-w-2xl text-base leading-7 text-white/68">
|
|
||||||
Start a run with two lives, guess the higher hidden stat, and let the
|
|
||||||
server reveal beatmap playcount, mapper totals, or artist totals only
|
|
||||||
after you answer.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 grid gap-4 lg:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="mb-3 text-[11px] uppercase tracking-[0.18em] text-white/45">
|
|
||||||
Choose your mode
|
|
||||||
</p>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{modeOptions.map((option) => {
|
|
||||||
const isActive = option.value === modeSelection;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`w-full rounded-2xl border px-4 py-4 text-left transition ${
|
|
||||||
isActive
|
|
||||||
? 'border-[#ff66aa]/70 bg-[#ff66aa]/10 text-white'
|
|
||||||
: 'border-white/10 bg-white/5 text-white/78 hover:border-white/25'
|
|
||||||
}`}
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => setModeSelection(option.value)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<p className="text-lg font-medium text-white">{option.label}</p>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-white/60">
|
|
||||||
{option.helper}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="mb-3 text-[11px] uppercase tracking-[0.18em] text-white/45">
|
|
||||||
Choose your timer
|
|
||||||
</p>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{timerOptions.map((option) => {
|
|
||||||
const isActive = option.value === timerProfile;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`w-full rounded-2xl border px-4 py-4 text-left transition ${
|
|
||||||
isActive
|
|
||||||
? 'border-[#ff66aa]/70 bg-[#ff66aa]/10 text-white'
|
|
||||||
: 'border-white/10 bg-white/5 text-white/78 hover:border-white/25'
|
|
||||||
}`}
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => setTimerProfile(option.value)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<BaseIcon
|
|
||||||
className={isActive ? 'text-[#ff99c6]' : 'text-white/55'}
|
|
||||||
path={option.icon}
|
|
||||||
size={18}
|
|
||||||
/>
|
|
||||||
<p className="text-lg font-medium text-white">{option.label}</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-white/60">
|
|
||||||
{option.helper}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
|
||||||
<BaseButton
|
|
||||||
color="info"
|
|
||||||
disabled={isStarting}
|
|
||||||
icon={mdiGamepadSquareOutline}
|
|
||||||
label={isStarting ? 'Starting...' : 'Start run'}
|
|
||||||
onClick={handleStartSession}
|
|
||||||
/>
|
|
||||||
<BaseButton
|
|
||||||
color="whiteDark"
|
|
||||||
href="/play/history"
|
|
||||||
icon={mdiHistory}
|
|
||||||
label="View run history"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] uppercase tracking-[0.18em] text-white/45">
|
|
||||||
All-time ladder
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xl font-semibold text-white">
|
|
||||||
Top current streaks
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{leaderboard.length ? (
|
|
||||||
leaderboard.slice(0, 5).map((entry, index) => (
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/5 px-4 py-3"
|
|
||||||
key={entry.id}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-white/55">#{index + 1}</p>
|
|
||||||
<p className="mt-1 text-base font-medium text-white">
|
|
||||||
{entry.user.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-xl font-semibold text-white">{entry.score}</p>
|
|
||||||
<p className="text-xs uppercase tracking-[0.16em] text-white/45">
|
|
||||||
correct guesses
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-6 text-sm text-white/55">
|
|
||||||
Finish your first run to create a leaderboard entry.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
|
|
||||||
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
|
||||||
<p className="text-[11px] uppercase tracking-[0.18em] text-white/45">
|
|
||||||
Recent runs
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
{history.length ? (
|
|
||||||
history.slice(0, 3).map((item) => (
|
|
||||||
<Link
|
|
||||||
className="block rounded-2xl border border-white/10 bg-white/5 px-4 py-3 transition hover:border-white/20"
|
|
||||||
href={`/play/sessions/${item.id}`}
|
|
||||||
key={item.id}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-base font-medium text-white">
|
|
||||||
{formatModeSelection(item.modeSelection)} · {item.correctGuesses} correct
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-white/55">
|
|
||||||
{formatSessionDate(item.endedAt || item.startedAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-white/65">
|
|
||||||
Best {item.bestStreak}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-6 text-sm text-white/55">
|
|
||||||
No runs yet. Start a session to build your history.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isLoadingState && activeRound ? (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-[#10141d] px-4 py-3 text-sm text-white/70">
|
|
||||||
{currentRound ? (
|
|
||||||
<span>
|
|
||||||
Hidden values stay server-side until you answer. Pick A or B before the
|
|
||||||
timer expires.
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span>{getResultMessage(revealedRound)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-5 lg:grid-cols-2">
|
|
||||||
<GuessCard
|
|
||||||
card={activeRound.cards.a}
|
|
||||||
disabled={Boolean(revealedRound) || isSubmittingChoice}
|
|
||||||
onPick={currentRound ? submitChoice : undefined}
|
|
||||||
reveal={
|
|
||||||
revealedRound?.reveal
|
|
||||||
? {
|
|
||||||
show: true,
|
|
||||||
value: revealedRound.reveal.values.a,
|
|
||||||
valueSuffix: revealedRound.reveal.valueSuffix,
|
|
||||||
isWinner: revealedRound.reveal.winningChoice === 'a',
|
|
||||||
isWrongSelection:
|
|
||||||
!revealedRound.reveal.isCorrect &&
|
|
||||||
revealedRound.reveal.playerChoice === 'a',
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
slot="a"
|
|
||||||
/>
|
|
||||||
<GuessCard
|
|
||||||
card={activeRound.cards.b}
|
|
||||||
disabled={Boolean(revealedRound) || isSubmittingChoice}
|
|
||||||
onPick={currentRound ? submitChoice : undefined}
|
|
||||||
reveal={
|
|
||||||
revealedRound?.reveal
|
|
||||||
? {
|
|
||||||
show: true,
|
|
||||||
value: revealedRound.reveal.values.b,
|
|
||||||
valueSuffix: revealedRound.reveal.valueSuffix,
|
|
||||||
isWinner: revealedRound.reveal.winningChoice === 'b',
|
|
||||||
isWrongSelection:
|
|
||||||
!revealedRound.reveal.isCorrect &&
|
|
||||||
revealedRound.reveal.playerChoice === 'b',
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
slot="b"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isLoadingState && gameOver && session ? (
|
|
||||||
<CardBox className="mt-6 border border-white/10 bg-[#10141d] text-white">
|
|
||||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-white/45">
|
|
||||||
Run complete
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 text-3xl font-semibold text-white">
|
|
||||||
{session.correctGuesses} correct guesses with a best streak of {session.bestStreak}.
|
|
||||||
</h2>
|
|
||||||
<p className="mt-3 text-base leading-7 text-white/65">
|
|
||||||
Review every reveal in the run detail page, then spin up another instant
|
|
||||||
session when you are ready.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<BaseButton
|
|
||||||
color="info"
|
|
||||||
icon={mdiRefresh}
|
|
||||||
label="Start another run"
|
|
||||||
onClick={handleStartSession}
|
|
||||||
/>
|
|
||||||
<BaseButton
|
|
||||||
color="whiteDark"
|
|
||||||
href={`/play/sessions/${session.id}`}
|
|
||||||
label="View run details"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
) : null}
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PlayPage.getLayout = function getLayout(page: ReactElement) {
|
|
||||||
return <LayoutAuthenticated permission="READ_GAME_SESSIONS">{page}</LayoutAuthenticated>;
|
|
||||||
};
|
|
||||||
@ -1,294 +0,0 @@
|
|||||||
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>;
|
|
||||||
};
|
|
||||||
@ -9,93 +9,83 @@ import SectionTitleLineWithButton from '../../components/SectionTitleLineWithBut
|
|||||||
import { getPageTitle } from '../../config'
|
import { getPageTitle } from '../../config'
|
||||||
import TableSync_jobs from '../../components/Sync_jobs/TableSync_jobs'
|
import TableSync_jobs from '../../components/Sync_jobs/TableSync_jobs'
|
||||||
import BaseButton from '../../components/BaseButton'
|
import BaseButton from '../../components/BaseButton'
|
||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||||
import CardBoxModal from '../../components/CardBoxModal';
|
import CardBoxModal from "../../components/CardBoxModal";
|
||||||
import DragDropFilePicker from '../../components/DragDropFilePicker';
|
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||||
import {
|
import {setRefetch, uploadCsv} from '../../stores/sync_jobs/sync_jobsSlice';
|
||||||
create as createSyncJob,
|
|
||||||
setRefetch,
|
|
||||||
uploadCsv,
|
import {hasPermission} from "../../helpers/userPermissions";
|
||||||
} from '../../stores/sync_jobs/sync_jobsSlice';
|
|
||||||
import { hasPermission } from '../../helpers/userPermissions';
|
|
||||||
|
|
||||||
const Sync_jobsTablesPage = () => {
|
const Sync_jobsTablesPage = () => {
|
||||||
const [filterItems, setFilterItems] = useState([]);
|
const [filterItems, setFilterItems] = useState([]);
|
||||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||||
const [isModalActive, setIsModalActive] = useState(false);
|
const [isModalActive, setIsModalActive] = useState(false);
|
||||||
const [isCatalogSyncing, setIsCatalogSyncing] = useState(false);
|
const [showTableView, setShowTableView] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
const [filters] = useState([{label: 'ErrorSummary', title: 'error_summary'},{label: 'JobPayloadJson', title: 'job_payload_json'},
|
const [filters] = useState([{label: 'ErrorSummary', title: 'error_summary'},{label: 'JobPayloadJson', title: 'job_payload_json'},
|
||||||
{label: 'ProcessedCount', title: 'processed_count', number: 'true'},{label: 'InsertedCount', title: 'inserted_count', number: 'true'},{label: 'UpdatedCount', title: 'updated_count', number: 'true'},{label: 'SkippedCount', title: 'skipped_count', number: 'true'},{label: 'ErrorCount', title: 'error_count', number: 'true'},
|
{label: 'ProcessedCount', title: 'processed_count', number: 'true'},{label: 'InsertedCount', title: 'inserted_count', number: 'true'},{label: 'UpdatedCount', title: 'updated_count', number: 'true'},{label: 'SkippedCount', title: 'skipped_count', number: 'true'},{label: 'ErrorCount', title: 'error_count', number: 'true'},
|
||||||
|
|
||||||
{label: 'StartedAt', title: 'started_at', date: 'true'},{label: 'FinishedAt', title: 'finished_at', date: 'true'},
|
{label: 'StartedAt', title: 'started_at', date: 'true'},{label: 'FinishedAt', title: 'finished_at', date: 'true'},
|
||||||
|
|
||||||
|
|
||||||
{label: 'TriggeredBy', title: 'triggered_by'},
|
{label: 'TriggeredBy', title: 'triggered_by'},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'JobType', title: 'job_type', type: 'enum', options: ['osu_sync_seed','osu_sync_incremental','osu_refresh_mappers','osu_refresh_artists','aggregate_mapper_totals','aggregate_artist_totals','cache_warmup']},{label: 'Status', title: 'status', type: 'enum', options: ['queued','running','succeeded','failed','canceled']},
|
{label: 'JobType', title: 'job_type', type: 'enum', options: ['osu_sync_seed','osu_sync_incremental','osu_refresh_mappers','osu_refresh_artists','aggregate_mapper_totals','aggregate_artist_totals','cache_warmup']},{label: 'Status', title: 'status', type: 'enum', options: ['queued','running','succeeded','failed','canceled']},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SYNC_JOBS');
|
||||||
|
|
||||||
|
|
||||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SYNC_JOBS');
|
const addFilter = () => {
|
||||||
|
const newItem = {
|
||||||
const addFilter = () => {
|
id: uniqueId(),
|
||||||
const newItem = {
|
fields: {
|
||||||
id: uniqueId(),
|
filterValue: '',
|
||||||
fields: {
|
filterValueFrom: '',
|
||||||
filterValue: '',
|
filterValueTo: '',
|
||||||
filterValueFrom: '',
|
selectedField: '',
|
||||||
filterValueTo: '',
|
},
|
||||||
selectedField: '',
|
};
|
||||||
},
|
newItem.fields.selectedField = filters[0].title;
|
||||||
|
setFilterItems([...filterItems, newItem]);
|
||||||
};
|
};
|
||||||
newItem.fields.selectedField = filters[0].title;
|
|
||||||
setFilterItems([...filterItems, newItem]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSync_jobsCSV = async () => {
|
const getSync_jobsCSV = async () => {
|
||||||
const response = await axios({url: '/sync_jobs?filetype=csv', method: 'GET',responseType: 'blob'});
|
const response = await axios({url: '/sync_jobs?filetype=csv', method: 'GET',responseType: 'blob'});
|
||||||
const type = response.headers['content-type']
|
const type = response.headers['content-type']
|
||||||
const blob = new Blob([response.data], { type: type })
|
const blob = new Blob([response.data], { type: type })
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = window.URL.createObjectURL(blob)
|
link.href = window.URL.createObjectURL(blob)
|
||||||
link.download = 'sync_jobsCSV.csv'
|
link.download = 'sync_jobsCSV.csv'
|
||||||
link.click()
|
link.click()
|
||||||
};
|
};
|
||||||
|
|
||||||
const runCatalogSync = async () => {
|
const onModalConfirm = async () => {
|
||||||
try {
|
if (!csvFile) return;
|
||||||
setIsCatalogSyncing(true);
|
await dispatch(uploadCsv(csvFile));
|
||||||
await dispatch(
|
dispatch(setRefetch(true));
|
||||||
createSyncJob({
|
setCsvFile(null);
|
||||||
job_type: 'osu_sync_seed',
|
setIsModalActive(false);
|
||||||
status: 'queued',
|
};
|
||||||
job_payload_json: JSON.stringify({ pages: 12, sort: 'plays_desc' }),
|
|
||||||
}),
|
|
||||||
).unwrap();
|
|
||||||
dispatch(setRefetch(true));
|
|
||||||
} finally {
|
|
||||||
setIsCatalogSyncing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onModalConfirm = async () => {
|
const onModalCancel = () => {
|
||||||
if (!csvFile) return;
|
setCsvFile(null);
|
||||||
await dispatch(uploadCsv(csvFile));
|
setIsModalActive(false);
|
||||||
dispatch(setRefetch(true));
|
};
|
||||||
setCsvFile(null);
|
|
||||||
setIsModalActive(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onModalCancel = () => {
|
|
||||||
setCsvFile(null);
|
|
||||||
setIsModalActive(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -104,21 +94,12 @@ const Sync_jobsTablesPage = () => {
|
|||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Sync_jobs" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Sync_jobs" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/sync_jobs/sync_jobs-new'} color='info' label='New Item'/>}
|
|
||||||
|
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/sync_jobs/sync_jobs-new'} color='info' label='New Item'/>}
|
||||||
{hasCreatePermission && (
|
|
||||||
<BaseButton
|
|
||||||
className={'mr-3'}
|
|
||||||
color='success'
|
|
||||||
label={isCatalogSyncing ? 'Syncing osu catalog…' : 'Sync / continue osu catalog'}
|
|
||||||
disabled={isCatalogSyncing}
|
|
||||||
onClick={runCatalogSync}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className={'mr-3'}
|
className={'mr-3'}
|
||||||
color='info'
|
color='info'
|
||||||
@ -126,38 +107,32 @@ const Sync_jobsTablesPage = () => {
|
|||||||
onClick={addFilter}
|
onClick={addFilter}
|
||||||
/>
|
/>
|
||||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getSync_jobsCSV} />
|
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getSync_jobsCSV} />
|
||||||
|
|
||||||
{hasCreatePermission && (
|
{hasCreatePermission && (
|
||||||
<BaseButton
|
<BaseButton
|
||||||
color='info'
|
color='info'
|
||||||
label='Upload CSV'
|
label='Upload CSV'
|
||||||
onClick={() => setIsModalActive(true)}
|
onClick={() => setIsModalActive(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='md:inline-flex items-center ms-auto'>
|
<div className='md:inline-flex items-center ms-auto'>
|
||||||
<div id='delete-rows-button'></div>
|
<div id='delete-rows-button'></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='md:inline-flex items-center ms-auto'>
|
<div className='md:inline-flex items-center ms-auto'>
|
||||||
<Link href={'/sync_jobs/sync_jobs-table'}>Switch to Table</Link>
|
<Link href={'/sync_jobs/sync_jobs-table'}>Switch to Table</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
|
||||||
{hasCreatePermission && (
|
<TableSync_jobs
|
||||||
<CardBox className='mb-6'>
|
filterItems={filterItems}
|
||||||
<p className='text-sm text-slate-500 dark:text-slate-300'>
|
setFilterItems={setFilterItems}
|
||||||
Use <strong>Sync / continue osu catalog</strong> before testing the game. Each run upserts into the existing catalog, refreshes mapper and artist totals, and if you leave <code>startPage</code> out it automatically continues from the next untouched page so your local pool keeps growing.
|
filters={filters}
|
||||||
</p>
|
showGrid={false}
|
||||||
</CardBox>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<TableSync_jobs
|
|
||||||
filterItems={filterItems}
|
|
||||||
setFilterItems={setFilterItems}
|
|
||||||
filters={filters}
|
|
||||||
showGrid={false}
|
|
||||||
/>
|
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
<CardBoxModal
|
<CardBoxModal
|
||||||
title='Upload CSV'
|
title='Upload CSV'
|
||||||
@ -181,9 +156,9 @@ const Sync_jobsTablesPage = () => {
|
|||||||
Sync_jobsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
Sync_jobsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated
|
||||||
|
|
||||||
permission={'READ_SYNC_JOBS'}
|
permission={'READ_SYNC_JOBS'}
|
||||||
|
|
||||||
>
|
>
|
||||||
{page}
|
{page}
|
||||||
</LayoutAuthenticated>
|
</LayoutAuthenticated>
|
||||||
|
|||||||
@ -193,7 +193,7 @@ const initialValues = {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
job_payload_json: '{"pages":12,"sort":"plays_desc"}',
|
job_payload_json: '',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user