forked from simrail/simrail.pro
feat(frontend, backend): add steam stats as N/A, replace steam stats with active players.
This commit is contained in:
parent
29569a0cb1
commit
daed28fa91
123
packages/backend/src/http/routes/activePlayer.ts
Normal file
123
packages/backend/src/http/routes/activePlayer.ts
Normal file
@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* See LICENSE for more.
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { TMProfile } from "../../mongo/profile.js";
|
||||
import { SuccessResponseBuilder } from "../responseBuilder.js";
|
||||
import { escapeRegexString, arrayGroupBy } from "../../util/functions.js";
|
||||
import { PlayerUtil } from "../../util/PlayerUtil.js";
|
||||
|
||||
interface ActiveTrain
|
||||
{
|
||||
server: string;
|
||||
player: TMProfile;
|
||||
trainNumber: string;
|
||||
trainName: string;
|
||||
username: string;
|
||||
steam: string;
|
||||
}
|
||||
|
||||
interface ActiveStation
|
||||
{
|
||||
server: string;
|
||||
player: TMProfile;
|
||||
stationName: string;
|
||||
stationShort: string;
|
||||
username: string;
|
||||
steam: string;
|
||||
}
|
||||
|
||||
export class ActivePlayersRoute
|
||||
{
|
||||
static load()
|
||||
{
|
||||
const app = Router();
|
||||
|
||||
app.get("/train", async (req, res) =>
|
||||
{
|
||||
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
|
||||
|
||||
let a: ActiveTrain[] = [];
|
||||
|
||||
for (const data of Object.values(client.trains)) {
|
||||
for (const d of data.filter(d => d.TrainData.ControlledBySteamID)) {
|
||||
const p = await PlayerUtil.getPlayer(d.TrainData.ControlledBySteamID!);
|
||||
p && a.push({
|
||||
server: d.ServerCode,
|
||||
player: p,
|
||||
trainNumber: d.TrainNoLocal,
|
||||
trainName: d.TrainName,
|
||||
|
||||
username: p?.username,
|
||||
steam: p?.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (s) {
|
||||
a = a.filter(d => s.some(c => c.test(d.server)) || s.some(c => c.test(d.username)) || s.some(c => c.test(d.steam)) || s.some(c => c.test(d.steam)) || s.some(c => c.test(d.trainName) || s.some(c => c.test(d.trainNumber))));
|
||||
}
|
||||
|
||||
a = arrayGroupBy(a, d => d.server);
|
||||
|
||||
res.json(
|
||||
new SuccessResponseBuilder()
|
||||
.setCode(200)
|
||||
.setData({ records: a })
|
||||
.toJSON(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
app.get("/station", async (req, res) =>
|
||||
{
|
||||
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
|
||||
|
||||
let a: ActiveStation[] = [];
|
||||
|
||||
for (const server of Object.keys(client.stations)) {
|
||||
for (const d of client.stations[server].filter(d => d.DispatchedBy.length && d.DispatchedBy[0]?.SteamId)) {
|
||||
const p = await PlayerUtil.getPlayer(d.DispatchedBy[0].SteamId!);
|
||||
p && a.push({
|
||||
server: server,
|
||||
player: p,
|
||||
stationName: d.Name,
|
||||
stationShort: d.Prefix,
|
||||
|
||||
username: p?.username,
|
||||
steam: p?.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (s) {
|
||||
a = a.filter(d => s.some(c => c.test(d.server)) || s.some(c => c.test(d.username)) || s.some(c => c.test(d.steam)) || s.some(c => c.test(d.steam)) || s.some(c => c.test(d.stationName) || s.some(c => c.test(d.stationShort))));
|
||||
}
|
||||
|
||||
|
||||
a = arrayGroupBy(a, d => d.server);
|
||||
|
||||
res.json(
|
||||
new SuccessResponseBuilder()
|
||||
.setCode(200)
|
||||
.setData({ records: a })
|
||||
.toJSON(),
|
||||
);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
@ -1,100 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* See LICENSE for more.
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { PipelineStage } from "mongoose";
|
||||
import { IProfile, MProfile } from "../../mongo/profile.js";
|
||||
import { SuccessResponseBuilder } from "../responseBuilder.js";
|
||||
import { escapeRegexString, removeProperties } from "../../util/functions.js";
|
||||
|
||||
const generateSearch = (regex: RegExp) => [
|
||||
{
|
||||
id: { $regex: regex },
|
||||
},
|
||||
{
|
||||
username: { $regex: regex },
|
||||
},
|
||||
];
|
||||
|
||||
const sortyByMap: Record<string, any> = {
|
||||
distance: { steamTrainDistance: -1 },
|
||||
points: { steamTrainScore: -1 },
|
||||
}
|
||||
|
||||
export class SteamLeaderboardRoute
|
||||
{
|
||||
static load()
|
||||
{
|
||||
const app = Router();
|
||||
|
||||
app.get("/train", async (req, res) =>
|
||||
{
|
||||
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
|
||||
|
||||
const filter: PipelineStage[] = [];
|
||||
|
||||
s && filter.push({
|
||||
$match: {
|
||||
$and: [
|
||||
...s.map(x => ({ $or: generateSearch(x) })),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const sortBy = sortyByMap[req.query.s?.toString() ?? 'distance'] ?? sortyByMap.distance;
|
||||
|
||||
const records = await MProfile.aggregate(filter)
|
||||
.sort(sortBy)
|
||||
.limit(10);
|
||||
|
||||
res.json(
|
||||
new SuccessResponseBuilder<{ records: Omit<IProfile, "_id" | "__v">[] }>()
|
||||
.setCode(200)
|
||||
.setData({ records: records.map(x => removeProperties<Omit<IProfile, "_id" | "__v">>(x, [ "_id", "__v" ])) })
|
||||
.toJSON(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
app.get("/station", async (req, res) =>
|
||||
{
|
||||
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
|
||||
|
||||
const filter: PipelineStage[] = [];
|
||||
|
||||
s && filter.push({
|
||||
$match: {
|
||||
$and: [
|
||||
...s.map(x => ({ $or: generateSearch(x) })),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const records = await MProfile.aggregate(filter)
|
||||
.sort({ steamDispatcherTime: -1 })
|
||||
.limit(10);
|
||||
|
||||
res.json(
|
||||
new SuccessResponseBuilder<{ records: Omit<IProfile, "_id" | "__v">[] }>()
|
||||
.setCode(200)
|
||||
.setData({ records: records.map(x => removeProperties<Omit<IProfile, "_id" | "__v">>(x, [ "_id", "__v" ])) })
|
||||
.toJSON(),
|
||||
);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
@ -22,7 +22,7 @@ import { LeaderboardRoute } from "./routes/leaderboard.js";
|
||||
import cors from "cors";
|
||||
import { StatsRoute } from "./routes/stats.js";
|
||||
import { LogRoute } from "./routes/log.js";
|
||||
import { SteamLeaderboardRoute } from "./routes/steamLeaderboard.js";
|
||||
import { ActivePlayersRoute } from "./routes/activePlayer.js";
|
||||
|
||||
export class ApiModule
|
||||
{
|
||||
@ -36,7 +36,7 @@ export class ApiModule
|
||||
router.use("/trains/", TrainsRoute.load());
|
||||
router.use("/profiles/", ProfilesRoute.load());
|
||||
router.use("/leaderboard/", LeaderboardRoute.load());
|
||||
router.use("/steam/leaderboard/", SteamLeaderboardRoute.load());
|
||||
router.use("/active/", ActivePlayersRoute.load());
|
||||
|
||||
router.use("/stats/", StatsRoute.load());
|
||||
router.use("/log/", LogRoute.load());
|
||||
|
@ -36,7 +36,33 @@ export class StationsModule
|
||||
{
|
||||
const time = (date.getTime() - joinedAt) || 0;
|
||||
|
||||
if (!player.dispatcherStats) player.dispatcherStats = {};
|
||||
if (player.flags.includes("private"))
|
||||
{
|
||||
player.trainStats = {
|
||||
[ "N/A" ]: {
|
||||
score: stats?.stats?.find(x => x.name === "SCORE")?.value ?? 0,
|
||||
distance: stats?.stats?.find(x => x.name === "DISTANCE_M")?.value ?? 0,
|
||||
time: 0,
|
||||
},
|
||||
};
|
||||
|
||||
player.dispatcherStats = {
|
||||
[ "N/A" ]: {
|
||||
time: (stats?.stats?.find(x => x.name === "DISPATCHER_TIME")?.value ?? 0) * 1000 * 60,
|
||||
},
|
||||
};
|
||||
|
||||
player.trainPoints = stats?.stats?.find(x => x.name === "SCORE")?.value ?? 0;
|
||||
player.trainDistance = stats?.stats?.find(x => x.name === "DISTANCE_M")?.value ?? 0;
|
||||
player.dispatcherTime = (stats?.stats?.find(x => x.name === "DISPATCHER_TIME")?.value ?? 0) * 1000 * 60;
|
||||
player.trainTime = 0;
|
||||
}
|
||||
|
||||
|
||||
if (!player.dispatcherStats)
|
||||
{
|
||||
player.dispatcherStats = {};
|
||||
}
|
||||
|
||||
if (player.dispatcherStats[ station.Name ] && isTruthyAndGreaterThanZero(player.dispatcherStats[ station.Name ].time + time))
|
||||
{
|
||||
@ -49,20 +75,23 @@ export class StationsModule
|
||||
};
|
||||
}
|
||||
|
||||
if (isTruthyAndGreaterThanZero(player.dispatcherTime + time)) player.dispatcherTime = player.dispatcherTime + time;
|
||||
if (isTruthyAndGreaterThanZero(player.dispatcherTime + time))
|
||||
{
|
||||
player.dispatcherTime = player.dispatcherTime + time;
|
||||
}
|
||||
|
||||
player.steamTrainDistance = stats?.stats?.find(x => x.name === "DISTANCE_M")?.value ?? 0;
|
||||
player.steamDispatcherTime = stats?.stats?.find(x => x.name === "DISPATCHER_TIME")?.value ?? 0;
|
||||
player.steamTrainScore = stats?.stats?.find(x => x.name === "SCORE")?.value ?? 0;
|
||||
|
||||
|
||||
player.flags = player.flags.filter(x => x !== "private");
|
||||
}
|
||||
|
||||
const playerData = await PlayerUtil.getPlayerSteamData(player.id);
|
||||
|
||||
!stats && !player.flags.includes('private') && player.flags.push("private");
|
||||
|
||||
player.flags = [...new Set(player.flags)];
|
||||
player.flags = [ ...new Set(player.flags) ];
|
||||
|
||||
player.username = playerData?.personaname ?? player.username;
|
||||
player.avatar = playerData?.avatarfull ?? player.avatar;
|
||||
|
@ -36,6 +36,28 @@ export class TrainsModule
|
||||
{
|
||||
const time = (leftAt - joinedAt) || 0;
|
||||
|
||||
if (player.flags.includes("private"))
|
||||
{
|
||||
player.trainStats = {
|
||||
[ "N/A" ]: {
|
||||
score: stats?.stats?.find(x => x.name === "SCORE")?.value ?? 0,
|
||||
distance: stats?.stats?.find(x => x.name === "DISTANCE_M")?.value ?? 0,
|
||||
time: 0,
|
||||
},
|
||||
};
|
||||
|
||||
player.dispatcherStats = {
|
||||
[ "N/A" ]: {
|
||||
time: (stats?.stats?.find(x => x.name === "DISPATCHER_TIME")?.value ?? 0) * 1000 * 60,
|
||||
},
|
||||
};
|
||||
|
||||
player.trainPoints = stats?.stats?.find(x => x.name === "SCORE")?.value ?? 0;
|
||||
player.trainDistance = stats?.stats?.find(x => x.name === "DISTANCE_M")?.value ?? 0;
|
||||
player.dispatcherTime = (stats?.stats?.find(x => x.name === "DISPATCHER_TIME")?.value ?? 0) * 1000 * 60;
|
||||
player.trainTime = 0;
|
||||
}
|
||||
|
||||
const vehicleName = getVehicle(vehicle) ?? vehicle;
|
||||
|
||||
if (!isTruthyAndGreaterThanZero(distance))
|
||||
|
@ -59,12 +59,51 @@ export class PlayerUtil
|
||||
{
|
||||
const data = await steamFetch(`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=${ STEAM_API_KEY }&format=json&steamids=${ steamId }`, 20) as IPlayerPayload;
|
||||
|
||||
assert(data.response.players, "Expected data.response.players to be truthy")
|
||||
assert(data.response.players, "Expected data.response.players to be truthy");
|
||||
|
||||
const stats = await this.getPlayerStats(steamId);
|
||||
|
||||
player = await MProfile.findOne({ id: steamId });
|
||||
if (player) return player;
|
||||
if (player)
|
||||
{
|
||||
return player;
|
||||
}
|
||||
|
||||
|
||||
const trainStats: {
|
||||
[ trainName: string ]: {
|
||||
score: number,
|
||||
distance: number
|
||||
time: number,
|
||||
}
|
||||
} = {};
|
||||
const dispatcherStats: {
|
||||
[ name: string ]: {
|
||||
time: number
|
||||
}
|
||||
|
||||
} = {};
|
||||
|
||||
let trainPoints = 0;
|
||||
let trainDistance = 0;
|
||||
let dispatcherTime = 0;
|
||||
|
||||
if (stats)
|
||||
{
|
||||
trainStats['N/A'] = {
|
||||
score: stats?.stats?.find(x => x.name === "SCORE")?.value ?? 0,
|
||||
distance: stats?.stats?.find(x => x.name === "DISTANCE_M")?.value ?? 0,
|
||||
time: 0,
|
||||
};
|
||||
|
||||
dispatcherStats['N/A'] = {
|
||||
time: (stats?.stats?.find(x => x.name === "DISPATCHER_TIME")?.value ?? 0) * 1000 * 60
|
||||
};
|
||||
|
||||
trainPoints = stats?.stats?.find(x => x.name === "SCORE")?.value ?? 0;
|
||||
trainDistance = stats?.stats?.find(x => x.name === "DISTANCE_M")?.value ?? 0;
|
||||
dispatcherTime = (stats?.stats?.find(x => x.name === "DISPATCHER_TIME")?.value ?? 0) * 1000 * 60;
|
||||
}
|
||||
|
||||
player = await MProfile.create({
|
||||
id: steamId,
|
||||
@ -75,20 +114,22 @@ export class PlayerUtil
|
||||
steamTrainScore: stats?.stats?.find(x => x.name === "SCORE")?.value ?? 0,
|
||||
steamTrainDistance: stats?.stats?.find(x => x.name === "DISTANCE_M")?.value ?? 0,
|
||||
|
||||
trainStats: {},
|
||||
dispatcherStats: {},
|
||||
trainStats,
|
||||
dispatcherStats,
|
||||
|
||||
trainPoints: 0,
|
||||
trainDistance: 0,
|
||||
trainPoints,
|
||||
trainDistance,
|
||||
trainTime: 0,
|
||||
|
||||
dispatcherTime: 0,
|
||||
dispatcherTime,
|
||||
|
||||
flags: !stats ? [ 'private' ] : []
|
||||
flags: !stats ? [ "private" ] : [],
|
||||
}).catch(e => e);
|
||||
|
||||
if (player instanceof Error)
|
||||
{
|
||||
player = await MProfile.findOne({ id: steamId });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -97,21 +138,28 @@ export class PlayerUtil
|
||||
return player;
|
||||
}
|
||||
|
||||
public static async getPlayer(steamId: string) {
|
||||
public static async getPlayer(steamId: string)
|
||||
{
|
||||
const player = await MProfile.findOne({ id: steamId });
|
||||
|
||||
if (!player) return undefined;
|
||||
if (!player)
|
||||
{
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
public static async getPlayerSteamData(steamId: string) {
|
||||
public static async getPlayerSteamData(steamId: string)
|
||||
{
|
||||
const data = await steamFetch(`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=${ STEAM_API_KEY }&format=json&steamids=${ steamId }`, 5) as IPlayerPayload;
|
||||
|
||||
if (!data?.response?.players?.length)
|
||||
{
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return data.response.players[0];
|
||||
return data.response.players[ 0 ];
|
||||
}
|
||||
|
||||
public static async getPlayerStats(steamId: string)
|
||||
|
@ -33,4 +33,10 @@ export const escapeRegexString = (str: string) => {
|
||||
export const isTruthyAndGreaterThanZero = (data: number) => {
|
||||
if (!data) return false;
|
||||
return data > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const arrayGroupBy = <T>(array: T[], predicate: (value: T, index: number, array: T[]) => string) =>
|
||||
Object.values((array.reduce((acc, value, index, array) => {
|
||||
(acc[predicate(value, index, array)] ||= []).push(value);
|
||||
return acc;
|
||||
}, {} as { [key: string]: T[] }))).flat();
|
@ -32,8 +32,8 @@ import { ToastContainer } from "react-toastify";
|
||||
import useColorMode from "./hooks/useColorMode.tsx";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
import { PageMeta } from "./components/mini/util/PageMeta.tsx";
|
||||
import { SteamStationLeaderboard } from "./pages/steamLeaderboard/SteamStationsLeaderboard.tsx";
|
||||
import { SteamTrainLeaderboard } from "./pages/steamLeaderboard/SteamTrainLeaderboard.tsx";
|
||||
import { ActiveStationsPlayers } from "./pages/activePlayers/ActiveStationsPlayers.tsx";
|
||||
import { ActiveTrainPlayers } from "./pages/activePlayers/ActiveTrainPlayers.tsx";
|
||||
|
||||
function App()
|
||||
{
|
||||
@ -126,23 +126,23 @@ function App()
|
||||
|
||||
|
||||
<Route
|
||||
path="/leaderboard/steam/trains"
|
||||
path="/active/trains"
|
||||
element={
|
||||
<>
|
||||
<PageMeta title="simrail.pro | Steam Train Leaderboard"
|
||||
<PageMeta title="simrail.pro | Active Trains"
|
||||
description="Simrail Stats - The best SimRail logs and statistics site!"/>
|
||||
<SteamTrainLeaderboard/>
|
||||
<ActiveTrainPlayers/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/leaderboard/steam/stations"
|
||||
path="/active/stations"
|
||||
element={
|
||||
<>
|
||||
<PageMeta title="simrail.pro | Steam Station Leaderboard"
|
||||
<PageMeta title="simrail.pro | Active Station"
|
||||
description="Simrail Stats - The best SimRail logs and statistics site!"/>
|
||||
<SteamStationLeaderboard/>
|
||||
<ActiveStationsPlayers/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
@ -21,7 +21,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { HamburgerGoBackIcon } from "../icons/SidebarIcons.tsx";
|
||||
import { ArrowIcon } from "../icons/ArrowIcon.tsx";
|
||||
import { FaHome, FaClipboardList } from "react-icons/fa";
|
||||
import { FaChartSimple, FaTrain, FaBuildingFlag, FaSteam } from "react-icons/fa6";
|
||||
import { FaChartSimple, FaTrain, FaBuildingFlag, FaBolt } from "react-icons/fa6";
|
||||
|
||||
interface SidebarProps
|
||||
{
|
||||
@ -274,7 +274,7 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
|
||||
|
||||
<SidebarLinkGroup
|
||||
activeCondition={
|
||||
pathname === "/leaderboard/steam" || pathname.includes("leaderboard/steam")
|
||||
pathname === "/active/trains" || pathname.includes("active/trains")
|
||||
}
|
||||
>
|
||||
{ (handleClick, open) =>
|
||||
@ -284,8 +284,8 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
|
||||
<NavLink
|
||||
to="#"
|
||||
className={ `group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${
|
||||
(pathname === "/leaderboard/steam" ||
|
||||
pathname.includes("leaderboard/steam")) &&
|
||||
(pathname === "/active" ||
|
||||
pathname.includes("/active/")) &&
|
||||
"bg-graydark dark:bg-meta-4"
|
||||
}` }
|
||||
onClick={ (e) =>
|
||||
@ -296,8 +296,8 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
|
||||
: setSidebarExpanded(true);
|
||||
} }
|
||||
>
|
||||
<FaSteam/>
|
||||
{ t("sidebar.steam_leaderboard") }
|
||||
<FaBolt />
|
||||
{ t("sidebar.active_players") }
|
||||
<ArrowIcon rotated={ open }/>
|
||||
</NavLink>
|
||||
<div
|
||||
@ -308,7 +308,7 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
|
||||
<ul className="mt-4 mb-5.5 flex flex-col gap-2.5 pl-6">
|
||||
<li>
|
||||
<NavLink
|
||||
to="/leaderboard/steam/stations"
|
||||
to="/active/stations"
|
||||
className={ ({ isActive }) =>
|
||||
"group relative flex items-center gap-2.5 rounded-md px-4 font-medium text-bodydark2 duration-300 ease-in-out hover:text-white " +
|
||||
(isActive && "!text-white")
|
||||
@ -320,7 +320,7 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink
|
||||
to="/leaderboard/steam/trains"
|
||||
to="/active/trains"
|
||||
className={ ({ isActive }) =>
|
||||
"group relative flex items-center gap-2.5 rounded-md px-4 font-medium text-bodydark2 duration-300 ease-in-out hover:text-white " +
|
||||
(isActive && "!text-white")
|
||||
|
@ -16,63 +16,70 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { TLeaderboardRecord } from "../../../types/leaderboard.ts";
|
||||
import { formatTime } from "../../../util/time.ts";
|
||||
import { FaCheck } from "react-icons/fa6";
|
||||
import { TActiveStationPlayersData } from "../../../types/active.ts";
|
||||
|
||||
export const SteamStationTable = ({ stations }: { stations: TLeaderboardRecord[] }) =>
|
||||
export const ActiveStationTable = ({ stations }: { stations: TActiveStationPlayersData[] }) =>
|
||||
{
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-sm border border-stroke bg-white px-5 pt-6 pb-2.5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-2 rounded-sm bg-gray-2 dark:bg-meta-4 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-3 rounded-sm bg-gray-2 dark:bg-meta-4 sm:grid-cols-4">
|
||||
<div className="p-2.5 text-center xl:p-5">
|
||||
<h5 className="text-sm font-medium uppercase xsm:text-base">
|
||||
{ t("leaderboard.user") }
|
||||
{ t("active.server") }
|
||||
</h5>
|
||||
</div>
|
||||
<div className="p-2.5 text-center xl:p-5">
|
||||
<h5 className="text-sm font-medium uppercase xsm:text-base">
|
||||
{ t("leaderboard.time") }
|
||||
{ t("active.user") }
|
||||
</h5>
|
||||
</div>
|
||||
<div className="p-2.5 text-center xl:p-5">
|
||||
<h5 className="text-sm font-medium uppercase xsm:text-base">
|
||||
{ t("active.station") }
|
||||
</h5>
|
||||
</div>
|
||||
<div className="hidden p-2.5 text-center sm:block xl:p-5">
|
||||
<h5 className="text-sm font-medium uppercase xsm:text-base">
|
||||
{ t("leaderboard.actions") }
|
||||
{ t("active.actions") }
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ stations.map((station, key) => (
|
||||
<div
|
||||
className={ `grid grid-cols-2 sm:grid-cols-3 ${ stations.length === (key + 1) // todo: ...
|
||||
className={ `grid grid-cols-3 sm:grid-cols-4 ${ stations.length === (key + 1) // todo: ...
|
||||
? ""
|
||||
: "border-b border-stroke dark:border-strokedark"
|
||||
}` }
|
||||
key={ station.id }
|
||||
key={ station.player.id }
|
||||
>
|
||||
<div className="flex items-center justify-center p-2.5 lg:p-5">
|
||||
<p className="text-meta-6">{ station.server.toUpperCase() }</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center items-center gap-3 p-5 lg:p-5">
|
||||
<p className="text-black dark:text-white sm:block break-all">
|
||||
<Link to={ "/profile/" + station.id }
|
||||
className="color-orchid">{ station.username }</Link> { station.flags.includes("verified") &&
|
||||
<Link to={ "/profile/" + station.steam }
|
||||
className="color-orchid">{ station.username }</Link> { station.player.flags.includes("verified") &&
|
||||
<FaCheck className={ "inline text-meta-3 ml-1" }/> }
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center p-2.5 lg:p-5">
|
||||
<p className="text-meta-3">{ formatTime(station.steamDispatcherTime * 1000 * 60) }</p>
|
||||
<p className="text-meta-5">{ station.stationName }</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center justify-center p-2.5 sm:flex xl:p-5">
|
||||
<Link
|
||||
to={ "/profile/" + station.id }
|
||||
to={ "/profile/" + station.steam }
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5"
|
||||
>
|
||||
{ t("leaderboard.profile") }
|
||||
{ t("active.profile") }
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
@ -16,15 +16,10 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { TLeaderboardRecord } from "../../../types/leaderboard.ts";
|
||||
import { FaCheck } from "react-icons/fa6";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { FlexArrowIcon } from "../../mini/icons/ArrowIcon.tsx";
|
||||
|
||||
export const SteamTrainTable = ({ trains, setSortBy, sortBy }: {
|
||||
trains: TLeaderboardRecord[],
|
||||
setSortBy: Dispatch<SetStateAction<string>>
|
||||
sortBy: string
|
||||
import { TActiveTrainPlayersData } from "../../../types/active.ts";
|
||||
export const ActiveTrainTable = ({ trains }: {
|
||||
trains: TActiveTrainPlayersData[],
|
||||
}) =>
|
||||
{
|
||||
const { t } = useTranslation();
|
||||
@ -37,26 +32,25 @@ export const SteamTrainTable = ({ trains, setSortBy, sortBy }: {
|
||||
<div className="grid grid-cols-3 rounded-sm bg-gray-2 dark:bg-meta-4 sm:grid-cols-4">
|
||||
<div className="p-2.5 text-center xl:p-5">
|
||||
<h5 className="text-sm font-medium uppercase xsm:text-base">
|
||||
{ t("leaderboard.user") }
|
||||
{ t("active.server") }
|
||||
</h5>
|
||||
</div>
|
||||
<div className="flex flex-row align-center justify-center gap-2 p-2.5 text-center xl:p-5">
|
||||
<h5 className="cursor-pointer text-sm font-medium uppercase xsm:text-base"
|
||||
onClick={ () => setSortBy("distance") }>
|
||||
{ t("leaderboard.distance") }
|
||||
|
||||
<div className="p-2.5 text-center xl:p-5">
|
||||
<h5 className="text-sm font-medium uppercase xsm:text-base">
|
||||
{ t("active.user") }
|
||||
</h5>
|
||||
<FlexArrowIcon rotated={ sortBy === "distance" || !sortBy }/>
|
||||
</div>
|
||||
<div className="flex flex-row align-center justify-center gap-2 p-2.5 text-center xl:p-5">
|
||||
<h5 className="cursor-pointer text-sm font-medium uppercase xsm:text-base"
|
||||
onClick={ () => setSortBy("points") }>
|
||||
{ t("leaderboard.points") }
|
||||
|
||||
<div className="p-2.5 text-center xl:p-5">
|
||||
<h5 className="text-sm font-medium uppercase xsm:text-base">
|
||||
{ t("active.train") }
|
||||
</h5>
|
||||
<FlexArrowIcon rotated={ sortBy === "points" }/>
|
||||
</div>
|
||||
|
||||
<div className="hidden p-2.5 text-center sm:block xl:p-5">
|
||||
<h5 className="text-sm font-medium uppercase xsm:text-base">
|
||||
{ t("leaderboard.actions") }
|
||||
{ t("active.actions") }
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
@ -67,30 +61,31 @@ export const SteamTrainTable = ({ trains, setSortBy, sortBy }: {
|
||||
? ""
|
||||
: "border-b border-stroke dark:border-strokedark"
|
||||
}` }
|
||||
key={ train.id }
|
||||
key={ train.steam }
|
||||
>
|
||||
<div className="flex items-center justify-center p-2.5 lg:p-5">
|
||||
<p className="text-meta-6">{ train.server.toUpperCase() }</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-3 p-5 lg:p-5">
|
||||
<p className="text-black dark:text-white sm:block break-all">
|
||||
<Link to={ "/profile/" + train.id }
|
||||
className="color-orchid">{ train.username }</Link> { train.flags.includes("verified") &&
|
||||
<Link to={ "/profile/" + train.steam }
|
||||
className="color-orchid">{ train.username }</Link> { train.player.flags.includes("verified") &&
|
||||
<FaCheck className={ "inline text-meta-3 ml-1" }/> }
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center p-2.5 lg:p-5">
|
||||
<p className="text-meta-6">{ (train.steamTrainDistance / 1000).toFixed(2) }km</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center p-2.5 lg:p-5">
|
||||
<p className="text-meta-5">{ train.steamTrainScore }</p>
|
||||
<p className="text-meta-5">{ train.trainName } { train.trainNumber }</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center justify-center p-2.5 sm:flex xl:p-5">
|
||||
<Link
|
||||
to={ "/profile/" + train.id }
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5"
|
||||
to={ "/profile/" + train.steam }
|
||||
className={ `inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5 ${ train.player.flags.includes("private") ? "bg-opacity-50" : "" }` }
|
||||
style={ train.player.flags.includes("private") ? { pointerEvents: "none" } : undefined }
|
||||
>
|
||||
{ t("leaderboard.profile") }
|
||||
{ t("active.profile") }
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
@ -19,7 +19,7 @@ import { TLogTrainData } from "../../../types/log.ts";
|
||||
import dayjs from "dayjs";
|
||||
import { toast } from "react-toastify";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FaCheck } from 'react-icons/fa6';
|
||||
import { FaCheck } from "react-icons/fa6";
|
||||
|
||||
|
||||
export const TrainLog = ({ data }: { data: TLogTrainData }) =>
|
||||
@ -51,7 +51,8 @@ export const TrainLog = ({ data }: { data: TLogTrainData }) =>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="mb-1.5 text-2xl font-semibold text-black dark:text-white">
|
||||
{ data.player.username } { data.player.flags.includes('verified') && <FaCheck className={ "inline text-meta-3 ml-1" }/> }
|
||||
{ data.player.username } { data.player.flags.includes("verified") &&
|
||||
<FaCheck className={ "inline text-meta-3 ml-1" }/> }
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
@ -62,10 +63,17 @@ export const TrainLog = ({ data }: { data: TLogTrainData }) =>
|
||||
<h1 className="text-xl text-black dark:text-white pb-5">{ t("log.train.header") }</h1>
|
||||
<p>{ t("log.train.server", { server: data.server.toUpperCase() }) }</p>
|
||||
<p>{ t("log.train.train", { name: data.trainName, number: data.trainNumber }) }</p>
|
||||
{ (data.distance || data.distance === 0) &&
|
||||
<p>{ t("log.train.distance", { distance: (data.distance / 1000).toFixed(2) }) }</p> }
|
||||
|
||||
{ (data.points || data.points === 0) && <p>{ t("log.train.points", { points: data.points }) }</p> }
|
||||
{
|
||||
!data.player.flags.includes("private") &&
|
||||
<>
|
||||
{ (data.distance || data.distance === 0) &&
|
||||
<p>{ t("log.train.distance", { distance: (data.distance / 1000).toFixed(2) }) }</p> }
|
||||
|
||||
{ (data.points || data.points === 0) &&
|
||||
<p>{ t("log.train.points", { points: data.points }) }</p> }
|
||||
</>
|
||||
}
|
||||
|
||||
{ data.joinedDate &&
|
||||
<p>{ t("log.train.joined", { date: dayjs(data.joinedDate).format("DD/MM/YYYY HH:mm") }) }</p> }
|
||||
|
@ -36,6 +36,14 @@
|
||||
"profile": "Profile",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"active": {
|
||||
"server": "Server",
|
||||
"user": "Player",
|
||||
"train": "Train",
|
||||
"station": "Station",
|
||||
"profile": "Profile",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"logs": {
|
||||
"user": "Player",
|
||||
"time": "Time",
|
||||
@ -133,7 +141,7 @@
|
||||
"stations": "Stations",
|
||||
"trains": "Trains",
|
||||
"leaderboard": "Leaderboard",
|
||||
"steam_leaderboard": "Steam leaderboard",
|
||||
"active_players": "Active Players",
|
||||
"info": "INFO",
|
||||
"admin": "ADMIN"
|
||||
},
|
||||
|
@ -36,6 +36,14 @@
|
||||
"profile": "Profil",
|
||||
"actions": "Akcje"
|
||||
},
|
||||
"active": {
|
||||
"server": "Serwer",
|
||||
"user": "Gracz",
|
||||
"train": "Pociąg",
|
||||
"station": "Stacja",
|
||||
"profile": "Profil",
|
||||
"actions": "Akcje"
|
||||
},
|
||||
"logs": {
|
||||
"user": "Gracz",
|
||||
"time": "Czas",
|
||||
@ -133,7 +141,7 @@
|
||||
"stations": "Stacje",
|
||||
"trains": "Pociągi",
|
||||
"leaderboard": "Tablica wyników",
|
||||
"steam_leaderboard": "Tablica wyników steam",
|
||||
"active_players": "Aktywni gracze",
|
||||
"info": "INFO",
|
||||
"admin": "ADMIN"
|
||||
},
|
||||
|
@ -20,18 +20,17 @@ import { useDebounce } from "use-debounce";
|
||||
import { Search } from "../../components/mini/util/Search.tsx";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { fetcher } from "../../util/fetcher.ts";
|
||||
import useSWR from 'swr';
|
||||
import useSWR from "swr";
|
||||
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
|
||||
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SteamStationTable } from "../../components/pages/steamLeaderboard/SteamStationTable.tsx";
|
||||
import { ActiveStationTable } from "../../components/pages/active/ActiveStationTable.tsx";
|
||||
|
||||
export const SteamStationLeaderboard = () =>
|
||||
export const ActiveStationsPlayers = () =>
|
||||
{
|
||||
const [ params, setParams ] = useState(new URLSearchParams());
|
||||
|
||||
const { data, error, isLoading } = useSWR(`/steam/leaderboard/station/?${params.toString()}`, fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
|
||||
|
||||
const { data, error, isLoading } = useSWR(`/active/station/?${ params.toString() }`, fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
|
||||
|
||||
const [ searchParams, setSearchParams ] = useSearchParams();
|
||||
const [ searchItem, setSearchItem ] = useState(searchParams.get("q") ?? "");
|
||||
@ -66,14 +65,16 @@ export const SteamStationLeaderboard = () =>
|
||||
<div className="flex flex-col gap-10">
|
||||
<Search handleInputChange={ handleInputChange } searchItem={ searchItem }/>
|
||||
<>
|
||||
{ error && <LoadError /> }
|
||||
{ error && <LoadError/> }
|
||||
|
||||
{ isLoading && <ContentLoader/> }
|
||||
|
||||
{ data && (data && data.code === 404) || (data && !data?.data?.records?.length) && <WarningAlert title={ t("content_loader.notfound.header") }
|
||||
description={ t("content_loader.notfound.description") }/> }
|
||||
{ data && (data && data.code === 404) || (data && !data?.data?.records?.length) &&
|
||||
<WarningAlert title={ t("content_loader.notfound.header") }
|
||||
description={ t("content_loader.notfound.description") }/> }
|
||||
|
||||
{ data && data.code === 200 && data.data && !!data?.data?.records?.length && <SteamStationTable stations={ data.data.records } /> }
|
||||
{ data && data.code === 200 && data.data && !!data?.data?.records?.length &&
|
||||
<ActiveStationTable stations={ data.data.records }/> }
|
||||
</>
|
||||
|
||||
</div>
|
@ -24,18 +24,17 @@ import { fetcher } from "../../util/fetcher.ts";
|
||||
import useSWR from "swr";
|
||||
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
|
||||
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
|
||||
import { SteamTrainTable } from "../../components/pages/steamLeaderboard/SteamTrainTable.tsx";
|
||||
import { ActiveTrainTable } from "../../components/pages/active/ActiveTrainTable.tsx";
|
||||
|
||||
export const SteamTrainLeaderboard = () =>
|
||||
export const ActiveTrainPlayers = () =>
|
||||
{
|
||||
const [ params, setParams ] = useState(new URLSearchParams());
|
||||
|
||||
const { data, error, isLoading } = useSWR(`/steam/leaderboard/train/?${ params.toString() }`, fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
|
||||
const { data, error, isLoading } = useSWR(`/active/train/?${ params.toString() }`, fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
|
||||
|
||||
|
||||
const [ searchParams, setSearchParams ] = useSearchParams();
|
||||
const [ searchItem, setSearchItem ] = useState(searchParams.get("q") ?? "");
|
||||
const [ sortBy, setSortBy ] = useState("distance");
|
||||
const [ searchValue ] = useDebounce(searchItem, 500);
|
||||
|
||||
useEffect(() =>
|
||||
@ -44,12 +43,11 @@ export const SteamTrainLeaderboard = () =>
|
||||
|
||||
const params = new URLSearchParams();
|
||||
searchValue && params.set("q", searchValue);
|
||||
sortBy && params.set("s", sortBy);
|
||||
|
||||
|
||||
setSearchParams(params.toString());
|
||||
setParams(params);
|
||||
}, [ searchValue, sortBy ]);
|
||||
}, [ searchValue ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
@ -76,7 +74,7 @@ export const SteamTrainLeaderboard = () =>
|
||||
description={ t("content_loader.notfound.description") }/>
|
||||
}
|
||||
|
||||
{ data && data.code === 200 && !!data?.data?.records?.length && <SteamTrainTable trains={ data?.data?.records } setSortBy={ setSortBy } sortBy={ sortBy }/> }
|
||||
{ data && data.code === 200 && !!data?.data?.records?.length && <ActiveTrainTable trains={ data?.data?.records } /> }
|
||||
</>
|
||||
</div>
|
||||
</>
|
50
packages/frontend/src/types/active.ts
Normal file
50
packages/frontend/src/types/active.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* See LICENSE for more.
|
||||
*/
|
||||
|
||||
|
||||
import { TProfilePlayer } from "./profile.ts";
|
||||
|
||||
export interface TActiveTrainPlayersResponse
|
||||
{
|
||||
success: boolean;
|
||||
data: TActiveTrainPlayersData;
|
||||
code: number;
|
||||
}
|
||||
|
||||
export interface TActiveTrainPlayersData {
|
||||
"server": string,
|
||||
"player": TProfilePlayer,
|
||||
"trainNumber": string,
|
||||
"trainName": string,
|
||||
"username": string,
|
||||
"steam": string
|
||||
}
|
||||
|
||||
export interface TActiveStationPlayersResponse
|
||||
{
|
||||
success: boolean;
|
||||
data: TActiveStationPlayersData;
|
||||
code: number;
|
||||
}
|
||||
|
||||
export interface TActiveStationPlayersData {
|
||||
"server": string,
|
||||
"player": TProfilePlayer,
|
||||
"stationName": string,
|
||||
"stationShort": string,
|
||||
"username": string,
|
||||
"steam": string
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user