feat(frontend, backend): add steam stats as N/A, replace steam stats with active players.

This commit is contained in:
Aleksander Wilczyński 2024-12-04 00:55:32 +01:00
parent 29569a0cb1
commit daed28fa91
Signed by untrusted user: alekswilc
GPG Key ID: D4464A248E5F27FE
17 changed files with 407 additions and 204 deletions

View 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;
}
}

View File

@ -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;
}
}

View File

@ -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());

View File

@ -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;

View File

@ -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))

View File

@ -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)

View File

@ -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();

View File

@ -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/>
</>
}
/>

View File

@ -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")

View File

@ -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>

View File

@ -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>

View File

@ -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> }

View File

@ -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"
},

View File

@ -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"
},

View File

@ -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>

View File

@ -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>
</>

View 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
}