Autosave: 20260506-160338
This commit is contained in:
parent
94972c3028
commit
47a2f113a1
BIN
assets/pasted-20260506-160112-7c934626.png
Normal file
BIN
assets/pasted-20260506-160112-7c934626.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 270 KiB |
@ -6,7 +6,6 @@ const passport = require('passport');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const bodyParser = require('body-parser');
|
||||
const db = require('./db/models');
|
||||
const config = require('./config');
|
||||
const swaggerUI = require('swagger-ui-express');
|
||||
const swaggerJsDoc = require('swagger-jsdoc');
|
||||
@ -54,6 +53,7 @@ const leaderboard_entriesRoutes = require('./routes/leaderboard_entries');
|
||||
const daily_seedsRoutes = require('./routes/daily_seeds');
|
||||
|
||||
const server_cachesRoutes = require('./routes/server_caches');
|
||||
const gameplayRoutes = require('./routes/gameplay');
|
||||
|
||||
|
||||
const getBaseUrl = (url) => {
|
||||
@ -146,6 +146,7 @@ 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/server_caches', passport.authenticate('jwt', {session: false}), server_cachesRoutes);
|
||||
app.use('/api/gameplay', passport.authenticate('jwt', {session: false}), gameplayRoutes);
|
||||
|
||||
app.use(
|
||||
'/api/openai',
|
||||
|
||||
58
backend/src/routes/gameplay.js
Normal file
58
backend/src/routes/gameplay.js
Normal file
@ -0,0 +1,58 @@
|
||||
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;
|
||||
1108
backend/src/services/gameplay.js
Normal file
1108
backend/src/services/gameplay.js
Normal file
File diff suppressed because it is too large
Load Diff
773
backend/src/services/osuCatalogSync.js
Normal file
773
backend/src/services/osuCatalogSync.js
Normal file
@ -0,0 +1,773 @@
|
||||
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,36 +1,107 @@
|
||||
const db = require('../db/models');
|
||||
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 OsuCatalogSyncService = require('./osuCatalogSync');
|
||||
const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
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 {
|
||||
static async create(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
const shouldRunJob =
|
||||
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 {
|
||||
await Sync_jobsDBApi.create(
|
||||
data,
|
||||
const result = await OsuCatalogSyncService.run(syncJob, currentUser);
|
||||
|
||||
await db.sync_jobs.update(
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
status: 'succeeded',
|
||||
finished_at: new Date(),
|
||||
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 },
|
||||
},
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
return db.sync_jobs.findByPk(syncJob.id);
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('Sync job failed:', error);
|
||||
await markJobFailed(syncJob.id, currentUser, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
||||
static async bulkImport(req, res) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
@ -38,24 +109,24 @@ module.exports = class Sync_jobsService {
|
||||
const bufferStream = new stream.PassThrough();
|
||||
const results = [];
|
||||
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8'));
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
bufferStream
|
||||
.pipe(csv())
|
||||
.on('data', (data) => results.push(data))
|
||||
.on('end', async () => {
|
||||
.on('data', (row) => results.push(row))
|
||||
.on('end', () => {
|
||||
console.log('CSV results', results);
|
||||
resolve();
|
||||
})
|
||||
.on('error', (error) => reject(error));
|
||||
})
|
||||
});
|
||||
|
||||
await Sync_jobsDBApi.bulkImport(results, {
|
||||
transaction,
|
||||
ignoreDuplicates: true,
|
||||
validate: true,
|
||||
currentUser: req.currentUser
|
||||
transaction,
|
||||
ignoreDuplicates: true,
|
||||
validate: true,
|
||||
currentUser: req.currentUser,
|
||||
});
|
||||
|
||||
await transaction.commit();
|
||||
@ -67,16 +138,15 @@ module.exports = class Sync_jobsService {
|
||||
|
||||
static async update(data, id, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
let sync_jobs = await Sync_jobsDBApi.findBy(
|
||||
{id},
|
||||
{transaction},
|
||||
const sync_jobs = await Sync_jobsDBApi.findBy(
|
||||
{ id },
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
if (!sync_jobs) {
|
||||
throw new ValidationError(
|
||||
'sync_jobsNotFound',
|
||||
);
|
||||
throw new ValidationError('sync_jobsNotFound');
|
||||
}
|
||||
|
||||
const updatedSync_jobs = await Sync_jobsDBApi.update(
|
||||
@ -90,12 +160,11 @@ module.exports = class Sync_jobsService {
|
||||
|
||||
await transaction.commit();
|
||||
return updatedSync_jobs;
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async deleteByIds(ids, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
@ -131,8 +200,4 @@ module.exports = class Sync_jobsService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
import BaseIcon from './BaseIcon'
|
||||
|
||||
32
frontend/src/components/OsuHigherLower/AnimatedCount.tsx
Normal file
32
frontend/src/components/OsuHigherLower/AnimatedCount.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
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)}</>;
|
||||
}
|
||||
212
frontend/src/components/OsuHigherLower/GuessCard.tsx
Normal file
212
frontend/src/components/OsuHigherLower/GuessCard.tsx
Normal file
@ -0,0 +1,212 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
111
frontend/src/components/OsuHigherLower/types.ts
Normal file
111
frontend/src/components/OsuHigherLower/types.ts
Normal file
@ -0,0 +1,111 @@
|
||||
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,5 +1,4 @@
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
|
||||
@ -2,6 +2,18 @@ import * as icon from '@mdi/js';
|
||||
import { MenuAsideItem } from './interfaces'
|
||||
|
||||
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',
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
|
||||
@ -1,166 +1,264 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
mdiDatabaseOutline,
|
||||
mdiGamepadSquareOutline,
|
||||
mdiPlayCircleOutline,
|
||||
mdiShieldCheckOutline,
|
||||
mdiTimerOutline,
|
||||
mdiViewDashboardOutline,
|
||||
} from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
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';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
|
||||
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() {
|
||||
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 (
|
||||
<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>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('Osu Higher Lower')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your Osu Higher Lower app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
<div className="min-h-screen bg-[#0b0d12] text-white">
|
||||
<div className="mx-auto flex min-h-screen max-w-7xl flex-col px-6 py-6 lg:px-10">
|
||||
<header className="flex items-center justify-between gap-4 border-b border-white/10 pb-5">
|
||||
<Link className="flex items-center gap-3" href="/">
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/5">
|
||||
<BaseIcon className="text-[#ff66aa]" path={mdiGamepadSquareOutline} size={20} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-white/45">
|
||||
Osu Higher Lower
|
||||
</p>
|
||||
<p className="text-sm text-white/70">Fast browser guessing game</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<BaseButton color="whiteDark" href="/login" label="Login" />
|
||||
<BaseButton
|
||||
color="whiteDark"
|
||||
href="/dashboard"
|
||||
icon={mdiViewDashboardOutline}
|
||||
label="Admin interface"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 py-10 lg:py-14">
|
||||
<section className="grid items-center gap-10 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<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>
|
||||
</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) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
298
frontend/src/pages/play/history.tsx
Normal file
298
frontend/src/pages/play/history.tsx
Normal file
@ -0,0 +1,298 @@
|
||||
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>;
|
||||
};
|
||||
768
frontend/src/pages/play/index.tsx
Normal file
768
frontend/src/pages/play/index.tsx
Normal file
@ -0,0 +1,768 @@
|
||||
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>;
|
||||
};
|
||||
294
frontend/src/pages/play/sessions/[sessionId].tsx
Normal file
294
frontend/src/pages/play/sessions/[sessionId].tsx
Normal file
@ -0,0 +1,294 @@
|
||||
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,83 +9,93 @@ import SectionTitleLineWithButton from '../../components/SectionTitleLineWithBut
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableSync_jobs from '../../components/Sync_jobs/TableSync_jobs'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
import {setRefetch, uploadCsv} from '../../stores/sync_jobs/sync_jobsSlice';
|
||||
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
|
||||
import axios from 'axios';
|
||||
import Link from 'next/link';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
import CardBoxModal from '../../components/CardBoxModal';
|
||||
import DragDropFilePicker from '../../components/DragDropFilePicker';
|
||||
import {
|
||||
create as createSyncJob,
|
||||
setRefetch,
|
||||
uploadCsv,
|
||||
} from '../../stores/sync_jobs/sync_jobsSlice';
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
|
||||
const Sync_jobsTablesPage = () => {
|
||||
const [filterItems, setFilterItems] = useState([]);
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [isModalActive, setIsModalActive] = useState(false);
|
||||
const [showTableView, setShowTableView] = useState(false);
|
||||
const [isCatalogSyncing, setIsCatalogSyncing] = useState(false);
|
||||
|
||||
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
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: 'StartedAt', title: 'started_at', date: 'true'},{label: 'FinishedAt', title: 'finished_at', date: 'true'},
|
||||
|
||||
|
||||
|
||||
|
||||
{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']},
|
||||
]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SYNC_JOBS');
|
||||
|
||||
|
||||
const addFilter = () => {
|
||||
const newItem = {
|
||||
id: uniqueId(),
|
||||
fields: {
|
||||
filterValue: '',
|
||||
filterValueFrom: '',
|
||||
filterValueTo: '',
|
||||
selectedField: '',
|
||||
},
|
||||
};
|
||||
newItem.fields.selectedField = filters[0].title;
|
||||
setFilterItems([...filterItems, newItem]);
|
||||
};
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SYNC_JOBS');
|
||||
|
||||
const getSync_jobsCSV = async () => {
|
||||
const response = await axios({url: '/sync_jobs?filetype=csv', method: 'GET',responseType: 'blob'});
|
||||
const type = response.headers['content-type']
|
||||
const blob = new Blob([response.data], { type: type })
|
||||
const link = document.createElement('a')
|
||||
link.href = window.URL.createObjectURL(blob)
|
||||
link.download = 'sync_jobsCSV.csv'
|
||||
link.click()
|
||||
const addFilter = () => {
|
||||
const newItem = {
|
||||
id: uniqueId(),
|
||||
fields: {
|
||||
filterValue: '',
|
||||
filterValueFrom: '',
|
||||
filterValueTo: '',
|
||||
selectedField: '',
|
||||
},
|
||||
};
|
||||
newItem.fields.selectedField = filters[0].title;
|
||||
setFilterItems([...filterItems, newItem]);
|
||||
};
|
||||
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
dispatch(setRefetch(true));
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
const getSync_jobsCSV = async () => {
|
||||
const response = await axios({url: '/sync_jobs?filetype=csv', method: 'GET',responseType: 'blob'});
|
||||
const type = response.headers['content-type']
|
||||
const blob = new Blob([response.data], { type: type })
|
||||
const link = document.createElement('a')
|
||||
link.href = window.URL.createObjectURL(blob)
|
||||
link.download = 'sync_jobsCSV.csv'
|
||||
link.click()
|
||||
};
|
||||
|
||||
const onModalCancel = () => {
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
const runCatalogSync = async () => {
|
||||
try {
|
||||
setIsCatalogSyncing(true);
|
||||
await dispatch(
|
||||
createSyncJob({
|
||||
job_type: 'osu_sync_seed',
|
||||
status: 'queued',
|
||||
job_payload_json: JSON.stringify({ pages: 12, sort: 'plays_desc' }),
|
||||
}),
|
||||
).unwrap();
|
||||
dispatch(setRefetch(true));
|
||||
} finally {
|
||||
setIsCatalogSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
dispatch(setRefetch(true));
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
|
||||
const onModalCancel = () => {
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -94,12 +104,21 @@ const Sync_jobsTablesPage = () => {
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Sync_jobs" main>
|
||||
{''}
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/sync_jobs/sync_jobs-new'} color='info' label='New Item'/>}
|
||||
|
||||
<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'}
|
||||
color='success'
|
||||
label={isCatalogSyncing ? 'Syncing osu catalog…' : 'Sync / continue osu catalog'}
|
||||
disabled={isCatalogSyncing}
|
||||
onClick={runCatalogSync}
|
||||
/>
|
||||
)}
|
||||
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
@ -107,32 +126,38 @@ const Sync_jobsTablesPage = () => {
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getSync_jobsCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<Link href={'/sync_jobs/sync_jobs-table'}>Switch to Table</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<Link href={'/sync_jobs/sync_jobs-table'}>Switch to Table</Link>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<TableSync_jobs
|
||||
filterItems={filterItems}
|
||||
setFilterItems={setFilterItems}
|
||||
filters={filters}
|
||||
showGrid={false}
|
||||
/>
|
||||
|
||||
|
||||
{hasCreatePermission && (
|
||||
<CardBox className='mb-6'>
|
||||
<p className='text-sm text-slate-500 dark:text-slate-300'>
|
||||
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.
|
||||
</p>
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
<TableSync_jobs
|
||||
filterItems={filterItems}
|
||||
setFilterItems={setFilterItems}
|
||||
filters={filters}
|
||||
showGrid={false}
|
||||
/>
|
||||
</SectionMain>
|
||||
<CardBoxModal
|
||||
title='Upload CSV'
|
||||
@ -156,9 +181,9 @@ const Sync_jobsTablesPage = () => {
|
||||
Sync_jobsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
|
||||
|
||||
permission={'READ_SYNC_JOBS'}
|
||||
|
||||
|
||||
>
|
||||
{page}
|
||||
</LayoutAuthenticated>
|
||||
|
||||
@ -193,7 +193,7 @@ const initialValues = {
|
||||
|
||||
|
||||
|
||||
job_payload_json: '',
|
||||
job_payload_json: '{"pages":12,"sort":"plays_desc"}',
|
||||
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user