feat(*): use SWR, add steam leaderboard, rewrite SimrailClient.ts, update DB structure.

This commit is contained in:
Aleksander Wilczyński 2024-12-01 23:15:24 +01:00
parent 0a3c70b108
commit 4ec78b998d
Signed by untrusted user: alekswilc
GPG Key ID: D4464A248E5F27FE
53 changed files with 5017 additions and 1030 deletions

3
packages/backend/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
# Keep environment variables out of version control
.env

View File

@ -12,7 +12,7 @@
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.9.0",
"@types/node": "^22.10.1",
"@types/uuid": "^10.0.0",
"copyfiles": "^2.4.1",
"typescript": "^5.5.4"

View File

@ -16,7 +16,7 @@
import { Router } from "express";
import { PipelineStage } from "mongoose";
import { IProfile, MProfile, raw_schema } from "../../mongo/profile.js";
import { IProfile, MProfile } from "../../mongo/profile.js";
import { SuccessResponseBuilder } from "../responseBuilder.js";
import { escapeRegexString, removeProperties } from "../../util/functions.js";
@ -54,6 +54,7 @@ export class LeaderboardRoute
],
},
});
const sortBy = sortyByMap[req.query.s?.toString() ?? 'distance'] ?? sortyByMap.distance;
const records = await MProfile.aggregate(filter)

View File

@ -15,12 +15,10 @@
*/
import { Router } from "express";
import { ITrainLog, MTrainLog } from "../../mongo/trainLogs.js";
import { MBlacklist } from "../../mongo/blacklist.js";
import { MTrainLog } from "../../mongo/trainLog.js";
import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js";
import { removeProperties } from "../../util/functions.js";
import { ILog, MLog } from "../../mongo/logs.js";
import { MProfile } from "../../mongo/profile.js";
import { MStationLog } from "../../mongo/stationLog.js";
import { IProfile } from "../../mongo/profile.js";
export class LogRoute
@ -40,7 +38,7 @@ export class LogRoute
return;
}
const log = await MLog.findOne({ id }) || await MTrainLog.findOne({ id });
const log = await MStationLog.findOne({ id }).populate<{ player: IProfile }>('player').orFail().catch(() => null) || await MTrainLog.findOne({ id }).populate<{ player: IProfile }>('player').orFail().catch(() => null);
if (!log)
{
@ -49,12 +47,9 @@ export class LogRoute
.setData("Invalid Id parameter").toJSON());
return;
}
const profile = await MProfile.findOne({ steam: log.userSteamId });
res.status(200).json(new SuccessResponseBuilder().setCode(200).setData({
verified: profile?.verified,
...removeProperties<Omit<(ILog | ITrainLog), "_id" | "__v">>(log.toJSON(), [ "_id", "__v" ])
...log.toJSON()
}));
});

View File

@ -15,12 +15,8 @@
*/
import { Router } from "express";
import { msToTime } from "../../util/time.js";
import { MProfile } from "../../mongo/profile.js";
import { MBlacklist } from "../../mongo/blacklist.js";
import { SteamUtil } from "../../util/SteamUtil.js";
import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js";
import { removeProperties } from "../../util/functions.js";
import { PlayerUtil } from "../../util/PlayerUtil.js";
export class ProfilesRoute
{
@ -36,30 +32,33 @@ export class ProfilesRoute
return;
}
const player = await MProfile.findOne({ steam: req.params.id });
const player = await PlayerUtil.getPlayer(req.params.id);
if (!player)
{
res.status(404).json(new ErrorResponseBuilder()
.setCode(404).setData("Profile not found! (probably private)"));
.setCode(404).setData("Profile not found!"));
return;
}
const blacklist = await MBlacklist.findOne({ steam: req.params.id! });
if (blacklist && blacklist.status)
if (player.flags.includes('blacklist'))
{
res.status(403).json(new ErrorResponseBuilder()
.setCode(403).setData("Profile blacklisted!"));
return;
}
const steam = await SteamUtil.getPlayer(player?.steam!);
const steamStats = await SteamUtil.getPlayerStats(player?.steam!);
if (player.flags.includes('private'))
{
res.status(404).json(new ErrorResponseBuilder()
.setCode(404).setData("Profile is private!"));
return;
}
res.json(
new SuccessResponseBuilder()
.setCode(200)
.setData({
player: removeProperties(player, ['_id', '__v']), steam, steamStats,
player
})
.toJSON(),
);

View File

@ -15,14 +15,9 @@
*/
import { Router } from "express";
import { ILog, MLog } from "../../mongo/logs.js";
import dayjs from "dayjs";
import { msToTime } from "../../util/time.js";
import { IStationLog, MStationLog } from "../../mongo/stationLog.js";
import { PipelineStage } from "mongoose";
import { MBlacklist } from "../../mongo/blacklist.js";
import { SteamUtil } from "../../util/SteamUtil.js";
import { GitUtil } from "../../util/git.js";
import { escapeRegexString, removeProperties } from "../../util/functions.js";
import { escapeRegexString } from "../../util/functions.js";
import { SuccessResponseBuilder } from "../responseBuilder.js";
import { MProfile } from "../../mongo/profile.js";
@ -31,13 +26,13 @@ const generateSearch = (regex: RegExp) => [
stationName: { $regex: regex },
},
{
userUsername: { $regex: regex },
username: { $regex: regex },
},
{
stationShort: { $regex: regex },
},
{
userSteamId: { $regex: regex },
steam: { $regex: regex },
},
{
server: { $regex: regex },
@ -53,10 +48,8 @@ export class StationsRoute
app.get("/", async (req, res) =>
{
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
const profiles = await MProfile.find({ verified: true });
const filter: PipelineStage[] = [];
s && filter.push({
$match: {
$and: [
@ -65,20 +58,16 @@ export class StationsRoute
},
});
const records = await MLog.aggregate(filter)
const records = await MStationLog.aggregate(filter)
.sort({ leftDate: -1 })
.limit(30);
await MProfile.populate(records, { path: "player" });
res.json(
new SuccessResponseBuilder<{ records: Omit<ILog, "_id" | "__v">[] }>()
new SuccessResponseBuilder<{ records: IStationLog[] }>()
.setCode(200)
.setData({ records: records.map(x => {
return {
...removeProperties<Omit<ILog, "_id" | "__v">>(x, [ "_id", "__v" ]),
verified: profiles.find(xx => xx.steam === x.userSteamId)
}
}) })
.setData({ records })
.toJSON(),
);
});

View File

@ -15,16 +15,10 @@
*/
import { Router } from "express";
import dayjs from "dayjs";
import { msToTime } from "../../util/time.js";
import { PipelineStage } from "mongoose";
import { ITrainLog, MTrainLog, raw_schema } from "../../mongo/trainLogs.js";
import { MBlacklist } from "../../mongo/blacklist.js";
import { SteamUtil } from "../../util/SteamUtil.js";
import { MTrainLog } from "../../mongo/trainLog.js";
import { GitUtil } from "../../util/git.js";
import { SuccessResponseBuilder } from "../responseBuilder.js";
import { removeProperties } from "../../util/functions.js";
import { MLog } from "../../mongo/logs.js";
import { MStationLog } from "../../mongo/stationLog.js";
import { MProfile } from "../../mongo/profile.js";
export class StatsRoute
@ -38,7 +32,7 @@ export class StatsRoute
const { commit, version } = GitUtil.getData();
const trains = await MTrainLog.countDocuments();
const dispatchers = await MLog.countDocuments();
const dispatchers = await MStationLog.countDocuments();
const profiles = await MProfile.countDocuments();
res.json(

View File

@ -0,0 +1,100 @@
/*
* 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> = {
points: { steamTrainDistance: -1 },
distance: { 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

@ -15,15 +15,10 @@
*/
import { Router } from "express";
import dayjs from "dayjs";
import { msToTime } from "../../util/time.js";
import { PipelineStage } from "mongoose";
import { ITrainLog, MTrainLog, raw_schema } from "../../mongo/trainLogs.js";
import { MBlacklist } from "../../mongo/blacklist.js";
import { SteamUtil } from "../../util/SteamUtil.js";
import { GitUtil } from "../../util/git.js";
import { ITrainLog, MTrainLog } from "../../mongo/trainLog.js";
import { SuccessResponseBuilder } from "../responseBuilder.js";
import { escapeRegexString, removeProperties } from "../../util/functions.js";
import { escapeRegexString } from "../../util/functions.js";
import { MProfile } from "../../mongo/profile.js";
const generateSearch = (regex: RegExp) => [
@ -31,13 +26,13 @@ const generateSearch = (regex: RegExp) => [
trainNumber: { $regex: regex },
},
{
userSteamId: { $regex: regex },
username: { $regex: regex },
},
{
server: { $regex: regex },
},
{
userUsername: { $regex: regex },
steam: { $regex: regex },
},
];
@ -50,7 +45,6 @@ export class TrainsRoute
app.get("/", async (req, res) =>
{
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
const profiles = await MProfile.find({ verified: true });
const filter: PipelineStage[] = [];
@ -67,18 +61,13 @@ export class TrainsRoute
.sort({ leftDate: -1 })
.limit(30);
await MProfile.populate(records, { path: "player" });
res.json(
new SuccessResponseBuilder<{ records: Omit<ITrainLog, "_id" | "__v">[] }>()
new SuccessResponseBuilder<{ records: ITrainLog[] }>()
.setCode(200)
.setData({
records: records.map(x =>
{
return {
...removeProperties<Omit<ITrainLog, "_id" | "__v">>(x, [ "_id", "__v" ]),
verified: profiles.find(xx => xx.steam === x.userSteamId)
};
}),
records
})
.toJSON(),
);

View File

@ -15,8 +15,6 @@
*/
import express, { Router } from "express";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { StationsRoute } from "./routes/stations.js";
import { TrainsRoute } from "./routes/trains.js";
import { ProfilesRoute } from "./routes/profile.js";
@ -24,6 +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";
export class ApiModule
{
@ -37,6 +36,8 @@ 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("/stats/", StatsRoute.load());
router.use("/log/", LogRoute.load());

View File

@ -22,8 +22,8 @@ import { ApiModule } from "./http/server.js";
import mongoose from "mongoose";
import { TrainsModule } from "./modules/trains.js";
import { Server, Station, Train } from "@simrail/types";
import { IPlayer } from "./types/player.js";
import dayjs from "dayjs";
import { TMProfile } from "./mongo/profile.js";
;(async () =>
{
@ -50,25 +50,25 @@ import dayjs from "dayjs";
if (process.env.NODE_ENV === "development")
{
client.on(SimrailClientEvents.StationJoined, (server: Server, station: Station, player: IPlayer) =>
client.on(SimrailClientEvents.StationJoined, (server: Server, station: Station, player: TMProfile) =>
{
console.log(`${ server.ServerCode } | ${ station.Name } | ${ player.personaname } joined`);
console.log(`${ server.ServerCode } | ${ station.Name } | ${ player.username } joined`);
});
client.on(SimrailClientEvents.StationLeft, (server: Server, station: Station, player: IPlayer, joinedAt: number) =>
client.on(SimrailClientEvents.StationLeft, (server: Server, station: Station, player: TMProfile, joinedAt: number) =>
{
console.log(`${ server.ServerCode } | ${ station.Name } | ${ player.personaname } left. | ${ joinedAt ? dayjs(joinedAt).fromNow() : "no time data." }`);
console.log(`${ server.ServerCode } | ${ station.Name } | ${ player.username } left. | ${ joinedAt ? dayjs(joinedAt).fromNow() : "no time data." }`);
});
client.on(SimrailClientEvents.TrainLeft, (server: Server, train: Train, player: IPlayer, joinedAt: number, leftAt: number, points: number, distance: number, vehicle: string) =>
client.on(SimrailClientEvents.TrainLeft, (server: Server, train: Train, player: TMProfile, joinedAt: number, leftAt: number, points: number, distance: number, vehicle: string) =>
{
console.log(`${ server.ServerCode } | ${ train.TrainName } | ${ player.personaname } left. | ${ joinedAt ? dayjs(joinedAt).fromNow() : "no time data." } |
console.log(`${ server.ServerCode } | ${ train.TrainName } | ${ player.username } left. | ${ joinedAt ? dayjs(joinedAt).fromNow() : "no time data." } |
${ vehicle } | ${ distance / 1000 } | ${ points }`);
});
client.on(SimrailClientEvents.TrainJoined, (server: Server, train: Train, player: IPlayer, start: number) =>
client.on(SimrailClientEvents.TrainJoined, (server: Server, train: Train, player: TMProfile, start: number) =>
{
console.log(`${ server.ServerCode } | ${ train.TrainName } | ${ player.personaname } joined | ${ start }`);
console.log(`${ server.ServerCode } | ${ train.TrainName } | ${ player.username } joined | ${ start }`);
});
}

View File

@ -15,68 +15,74 @@
*/
import { Server, Station } from "@simrail/types";
import { MLog } from "../mongo/logs.js";
import { IPlayer } from "../types/player.js";
import { MStationLog } from "../mongo/stationLog.js";
import { SimrailClientEvents } from "../util/SimrailClient.js";
import { v4 } from "uuid";
import { MProfile } from "../mongo/profile.js";
import { SteamUtil } from "../util/SteamUtil.js";
import { MProfile, TMProfile } from "../mongo/profile.js";
import { PlayerUtil } from "../util/PlayerUtil.js";
import { isTruthyAndGreaterThanZero } from "../util/functions.js";
export class StationsModule
{
public static load()
{
client.on(SimrailClientEvents.StationLeft, async (server: Server, station: Station, player: IPlayer, joinedAt: number) =>
client.on(SimrailClientEvents.StationLeft, async (server: Server, station: Station, player: TMProfile, joinedAt: number) =>
{
const stats = await SteamUtil.getPlayerStats(player.steamid);
const stats = await PlayerUtil.getPlayerStats(player.id);
const date = new Date();
if (stats)
if (stats && (date.getTime() - joinedAt) && (date.getTime() - joinedAt) > 0)
{
const time = (date.getTime() - joinedAt) || 0;
const userProfile = await MProfile.findOne({ steam: player.steamid }) ?? await MProfile.create({ steam: player.steamid, id: v4(), steamName: player.personaname });
if (!userProfile.dispatcherStats)
{
userProfile.dispatcherStats = {};
}
if (!player.dispatcherStats) player.dispatcherStats = {};
if (userProfile.dispatcherStats[ station.Name ])
if (player.dispatcherStats[ station.Name ] && isTruthyAndGreaterThanZero(player.dispatcherStats[ station.Name ].time + time))
{
userProfile.dispatcherStats[ station.Name ].time = userProfile.dispatcherStats[ station.Name ].time + time;
player.dispatcherStats[ station.Name ].time = player.dispatcherStats[ station.Name ].time + time;
}
else
{
userProfile.dispatcherStats[ station.Name ] = {
player.dispatcherStats[ station.Name ] = {
time,
};
}
if (Number.isNaN(userProfile.dispatcherStats[ station.Name ].time))
{
userProfile.dispatcherStats[ station.Name ].time = 0;
}
if (isTruthyAndGreaterThanZero(player.dispatcherTime + time)) player.dispatcherTime = player.dispatcherTime + time;
if (!userProfile.dispatcherTime)
{
userProfile.dispatcherTime = 0;
}
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;
userProfile.dispatcherTime = userProfile.dispatcherTime + time;
await MProfile.findOneAndUpdate({ id: userProfile.id }, { dispatcherStats: userProfile.dispatcherStats, dispatcherTime: userProfile.dispatcherTime });
player.flags = player.flags.filter(x => x !== "private");
}
await MLog.create({
const playerData = await PlayerUtil.getPlayerSteamData(player.id);
!stats && !player.flags.includes('private') && player.flags.push("private");
player.flags = [...new Set(player.flags)];
player.username = playerData?.personaname ?? player.username;
player.avatar = playerData?.avatarfull ?? player.avatar;
await MProfile.updateOne({ id: player.id }, player);
await MStationLog.create({
id: v4(),
userSteamId: player.steamid,
userAvatar: player.avatarfull,
userUsername: player.personaname,
steam: player.id,
username: player.username,
joinedDate: joinedAt,
leftDate: date.getTime(),
stationName: station.Name,
stationShort: station.Prefix,
server: server.ServerCode,
player: player._id,
});
});
}

View File

@ -15,80 +15,111 @@
*/
import { Server, Train } from "@simrail/types";
import { IPlayer } from "../types/player.js";
import { SimrailClientEvents } from "../util/SimrailClient.js";
import { v4 } from "uuid";
import { getVehicle } from "../util/contants.js";
import { MProfile } from "../mongo/profile.js";
import { MTrainLog } from "../mongo/trainLogs.js";
import { MProfile, TMProfile } from "../mongo/profile.js";
import { MTrainLog } from "../mongo/trainLog.js";
import { PlayerUtil } from "../util/PlayerUtil.js";
import { isTruthyAndGreaterThanZero } from "../util/functions.js";
export class TrainsModule
{
public static load()
{
client.on(SimrailClientEvents.TrainLeft, async (server: Server, train: Train, player: IPlayer, joinedAt: number, leftAt: number, points: number, distance: number, vehicle: string) =>
client.on(SimrailClientEvents.TrainLeft, async (server: Server, train: Train, player: TMProfile, joinedAt: number, leftAt: number, points: number, distance: number, vehicle: string) =>
{
if (distance)
const stats = await PlayerUtil.getPlayerStats(player.id);
if (stats && (leftAt - joinedAt) && (leftAt - joinedAt) > 0)
{
const time = (leftAt - joinedAt) || 0;
const userProfile = await MProfile.findOne({ steam: player.steamid }) ?? await MProfile.create({ steam: player.steamid, id: v4(), steamName: player.personaname });
const vehicleName = getVehicle(vehicle) ?? vehicle;
if (!userProfile.trainStats)
if (!isTruthyAndGreaterThanZero(distance))
distance = 0;
if (!isTruthyAndGreaterThanZero(points))
points = 0;
if (!player.trainStats)
{
userProfile.trainStats = {};
player.trainStats = {};
}
if (userProfile.trainStats[ vehicleName ])
if (player.trainStats[ vehicleName ])
{
userProfile.trainStats[ vehicleName ].distance = userProfile.trainStats[ vehicleName ].distance + distance;
userProfile.trainStats[ vehicleName ].score = userProfile.trainStats[ vehicleName ].score + points;
userProfile.trainStats[ vehicleName ].time = userProfile.trainStats[ vehicleName ].time + time;
if (isTruthyAndGreaterThanZero(player.trainStats[ vehicleName ].distance + distance))
{
player.trainStats[ vehicleName ].distance = player.trainStats[ vehicleName ].distance + distance;
}
if (isTruthyAndGreaterThanZero(player.trainStats[ vehicleName ].score + points))
{
player.trainStats[ vehicleName ].score = player.trainStats[ vehicleName ].score + points;
}
if (isTruthyAndGreaterThanZero(player.trainStats[ vehicleName ].time + time))
{
player.trainStats[ vehicleName ].time = player.trainStats[ vehicleName ].time + time;
}
}
else
{
userProfile.trainStats[ vehicleName ] = {
player.trainStats[ vehicleName ] = {
distance, score: points, time,
};
}
if (!userProfile.trainTime)
if (isTruthyAndGreaterThanZero(player.trainTime + time))
{
userProfile.trainTime = 0;
player.trainTime = player.trainTime + time;
}
userProfile.trainTime = userProfile.trainTime + time;
if (!userProfile.trainPoints)
if (isTruthyAndGreaterThanZero(player.trainPoints + points))
{
userProfile.trainPoints = 0;
player.trainPoints = player.trainPoints + points;
}
userProfile.trainPoints = userProfile.trainPoints + points;
if (!userProfile.trainDistance)
if (isTruthyAndGreaterThanZero(player.trainDistance + distance))
{
userProfile.trainDistance = 0;
player.trainDistance = player.trainDistance + distance;
}
userProfile.trainDistance = userProfile.trainDistance + distance;
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;
await MProfile.findOneAndUpdate({ id: userProfile.id }, { trainStats: userProfile.trainStats, trainTime: userProfile.trainTime, trainPoints: userProfile.trainPoints, trainDistance: userProfile.trainDistance });
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.username = playerData?.personaname ?? player.username;
player.avatar = playerData?.avatarfull ?? player.avatar;
await MProfile.updateOne({ id: player.id }, player);
await MTrainLog.create({
id: v4(),
userSteamId: player.steamid,
userAvatar: player.avatarfull,
userUsername: player.personaname,
steam: player.id,
username: player.username,
joinedDate: joinedAt,
leftDate: leftAt,
trainNumber: train.TrainNoLocal,
server: server.ServerCode,
distance, points,
trainName: train.TrainName,
player: player._id,
});
});
}

View File

@ -14,71 +14,100 @@
* See LICENSE for more.
*/
import { Model, model, Schema } from "mongoose";
import { HydratedDocument, model, Schema } from "mongoose";
export const raw_schema = {
// STEAM HEX
id: {
type: String,
required: true,
unique: true
},
steam: {
type: String,
required: true,
},
steamName: {
// USERNAME FROM STEAM
username: {
type: String,
required: true,
},
// AVATAR FROM STEAM
avatar: {
type: String,
required: true,
},
// OBJECT WITH TRAIN STATS
trainStats: {
type: Object,
required: false,
default: {},
},
// OBJECT WITH DISPATCHER STATS
dispatcherStats: {
type: Object,
required: false,
default: {},
},
// FULL TRAIN-TIME for easy access
trainTime: {
type: Number,
required: false,
default: 0,
},
// FULL TRAIN-SCORE for easy access
trainPoints: {
type: Number,
required: false,
default: 0,
},
// FULL TRAIN-DISTANCE for easy access
trainDistance: {
type: Number,
required: false,
default: 0,
},
// FULL DISPATCHER-TIME for easy access
dispatcherTime: {
type: Number,
required: false,
default: 0,
},
verified: {
type: Boolean,
required: true,
default: false
}
steamDispatcherTime: {
type: Number,
required: false,
default: 0,
},
steamTrainDistance: {
type: Number,
required: false,
default: 0,
},
steamTrainScore: {
type: Number,
required: false,
default: 0,
},
flags: [
{
type: String,
required: false,
default: []
}
]
};
const schema = new Schema<IProfile>(raw_schema);
export type TMProfile = Model<IProfile>
export type TMProfile = HydratedDocument<IProfile>;
export const MProfile = model<IProfile>("profile", schema);
export interface IProfile
{
id: string;
steam: string;
username: string;
avatar: string;
trainStats: {
[ trainName: string ]: {
score: number,
@ -92,10 +121,16 @@ export interface IProfile
}
};
dispatcherTime: number;
trainTime: number;
trainPoints: number;
steamName: string;
trainDistance: number;
verified: boolean;
dispatcherTime: number;
steamDispatcherTime: number;
steamTrainDistance: number;
steamTrainScore: number;
flags: string[]
}

View File

@ -22,18 +22,6 @@ export const raw_schema = {
type: String,
required: true,
},
userSteamId: {
type: String,
required: true,
},
userUsername: {
type: String,
required: true,
},
userAvatar: {
type: String,
required: true,
},
joinedDate: {
type: Number,
required: false,
@ -55,24 +43,40 @@ export const raw_schema = {
type: String,
required: true,
},
username: {
type: String,
required: true,
},
steam: {
type: String,
required: true,
},
player: {
type: Schema.Types.ObjectId,
ref: "profile"
}
};
const schema = new Schema<ILog>(raw_schema);
schema.index({ stationName: "text", userUsername: "text", stationShort: "text", userSteamId: "text", server: "text" });
const schema = new Schema<IStationLog>(raw_schema);
export type TMLog = Model<ILog>
export type TMStationLog = Model<IStationLog>
export const MLog = model<ILog>("logs", schema);
export const MStationLog = model<IStationLog>("stations", schema);
export interface ILog
export interface IStationLog
{
id: string;
userSteamId: string;
userUsername: string;
userAvatar: string;
joinedDate?: number;
leftDate: number;
stationName: string;
stationShort: string;
server: string;
player: Schema.Types.ObjectId;
username: string;
steam: string;
}

View File

@ -1,53 +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 { Model, model, Schema } from "mongoose";
export const raw_schema = {
steam: {
type: String,
required: true,
},
stats: {
type: Object,
required: false,
},
personaname: {
type: String,
required: true,
},
avatarfull: {
type: String,
required: true,
},
};
const schema = new Schema<ISteam>(raw_schema);
export type TMSteam = Model<ISteam>
export const MSteam = model<ISteam>("steam", schema);
export interface ISteam
{
steam: string;
stats: object;
personaname: string;
avatarfull: string;
lastUpdated: number;
}

View File

@ -26,18 +26,6 @@ export const raw_schema = {
type: String,
required: true,
},
userSteamId: {
type: String,
required: true,
},
userUsername: {
type: String,
required: true,
},
userAvatar: {
type: String,
required: true,
},
joinedDate: {
type: Number,
required: false,
@ -65,20 +53,29 @@ export const raw_schema = {
type: String,
default: null,
},
username: {
type: String,
required: true,
},
steam: {
type: String,
required: true,
},
player: {
type: Schema.Types.ObjectId,
ref: "profile"
}
};
const schema = new Schema<ITrainLog>(raw_schema);
export type TMTrainLog = Model<ITrainLog>
export const MTrainLog = model<ITrainLog>("train_logs", schema);
export const MTrainLog = model<ITrainLog>("trains", schema);
export interface ITrainLog
{
id: string;
userSteamId: string;
userUsername: string;
userAvatar: string;
joinedDate?: number;
leftDate: number;
trainNumber: string;
@ -86,4 +83,10 @@ export interface ITrainLog
distance: number;
points: number;
server: string;
player: Schema.Types.ObjectId;
username: string;
steam: string;
}

View File

@ -15,18 +15,20 @@
*/
import { IPlayerPayload, IPlayerStatsPayload } from "../types/player.js";
import { MProfile } from "../mongo/profile.js";
import { assert } from "node:console";
const STEAM_API_KEY = process.env.STEAM_APIKEY;
const fetchFuckingSteamApi = (url: string) =>
const steamFetch = (url: string, maxRetries: number = 5) =>
{
let retries = 0;
return new Promise((res, rej) =>
return new Promise((res, _rej) =>
{
const req = () =>
{
if (retries > 5)
if (retries > maxRetries)
{
throw new Error("request failed to api steam");
}
@ -47,25 +49,78 @@ const fetchFuckingSteamApi = (url: string) =>
export class PlayerUtil
{
public static async getPlayer(steamId: number | string)
public static async ensurePlayer(steamId: number | string)
{
const data = await fetchFuckingSteamApi(`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=${ STEAM_API_KEY }&format=json&steamids=${ steamId }`) as IPlayerPayload;
assert(steamId, "expected steamId to be a string or a number");
steamId = steamId.toString();
let player = await MProfile.findOne({ id: steamId });
if (!data.response.players)
if (!player)
{
return;
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")
const stats = await this.getPlayerStats(steamId);
player = await MProfile.findOne({ id: steamId });
if (player) return player;
player = await MProfile.create({
id: steamId,
username: data.response.players[ 0 ].personaname,
avatar: data.response.players[ 0 ].avatarfull,
steamDispatcherTime: stats?.stats?.find(x => x.name === "DISPATCHER_TIME")?.value ?? 0,
steamTrainScore: stats?.stats?.find(x => x.name === "SCORE")?.value ?? 0,
steamTrainDistance: stats?.stats?.find(x => x.name === "DISTANCE_M")?.value ?? 0,
trainStats: {},
dispatcherStats: {},
trainPoints: 0,
trainDistance: 0,
trainTime: 0,
dispatcherTime: 0,
flags: !stats ? [ 'private' ] : []
}).catch(e => e);
if (player instanceof Error)
player = await MProfile.findOne({ id: steamId });
}
return data.response.players[ 0 ];
assert(player, "expected player to be truthy");
return player;
}
public static async getPlayer(steamId: string) {
const player = await MProfile.findOne({ id: steamId });
if (!player) return undefined;
return player;
}
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];
}
public static async getPlayerStats(steamId: string)
{
const data = await fetchFuckingSteamApi(`http://api.steampowered.com/ISteamUserStats/GetUserStatsForGame/v0002/?appid=1422130&key=${ STEAM_API_KEY }&steamid=${ steamId }`) as IPlayerStatsPayload;
const data = await steamFetch(`https://api.steampowered.com/ISteamUserStats/GetUserStatsForGame/v0002/?appid=1422130&key=${ STEAM_API_KEY }&steamid=${ steamId }`) as IPlayerStatsPayload;
if (!data.playerstats?.stats)
{
return;
return undefined;
}
return data.playerstats;
}

View File

@ -15,10 +15,9 @@
*/
import { EventEmitter } from "node:events";
import { IPlayer } from "../types/player.js";
import { PlayerUtil } from "./PlayerUtil.js";
import { Station, ApiResponse, Server, Train } from "@simrail/types";
import { TMProfile } from "../mongo/profile.js";
export enum SimrailClientEvents
{
@ -31,14 +30,14 @@ export enum SimrailClientEvents
export declare interface SimrailClient
{
on(event: SimrailClientEvents.StationJoined, listener: (server: Server, station: Station, player: IPlayer) => void): this;
on(event: SimrailClientEvents.StationJoined, listener: (server: Server, station: Station, player: TMProfile) => void): this;
on(event: SimrailClientEvents.StationLeft, listener: (server: Server, station: Station, player: IPlayer, joinedAt: number) => void): this;
on(event: SimrailClientEvents.StationLeft, listener: (server: Server, station: Station, player: TMProfile, joinedAt: number) => void): this;
on(event: SimrailClientEvents.TrainJoined, listener: (server: Server, train: Train, player: IPlayer, startDistance: number) => void): this;
on(event: SimrailClientEvents.TrainJoined, listener: (server: Server, train: Train, player: TMProfile, startDistance: number) => void): this;
on(event: SimrailClientEvents.TrainLeft, listener: (server: Server, train: Train, player: IPlayer, joinedAt: number, leftAt: number, points: number, distance: number, vehicle: string) => void): this;
on(event: SimrailClientEvents.TrainLeft, listener: (server: Server, train: Train, player: TMProfile, joinedAt: number, leftAt: number, points: number, distance: number, vehicle: string) => void): this;
//on(event: string, listener: Function): this;
}
@ -66,30 +65,14 @@ export class SimrailClient extends EventEmitter
public constructor()
{
super();
this.setup();
setTimeout(() => setInterval(() => this.update(), 500), 1000);
}
public getStation(server: Server["ServerCode"], name: string)
{
if (!this.stationsOccupied[ server ] || !this.stationsOccupied[ server ][ name ])
{
return null;
}
const player = PlayerUtil.getPlayer(this.stationsOccupied[ server ][ name ]?.SteamId!);
return { player, joinedAt: this.stationsOccupied[ name ].joinedAt };
}
public getTrain(server: Server["ServerCode"], name: string)
{
if (!this.trainsOccupied[ server ] || !this.trainsOccupied[ server ][ name ])
{
return null;
}
const player = PlayerUtil.getPlayer(this.trainsOccupied[ server ][ name ]?.SteamId!);
return { player, joinedAt: this.trainsOccupied[ server ][ name ]?.JoinedAt, startPlayerDistance: this.trainsOccupied[ server ][ name ]?.StartPlayerDistance };
this.setup().then(() => {
void this.update();
});
}
// todo: full rewrite, rewrite db structure with option to join log to user profile, check for negative values in user profile
// todo: wipe database 13.12.2024
private async setup()
{
@ -161,7 +144,8 @@ export class SimrailClient extends EventEmitter
{
// join
const date = new Date();
const player = await PlayerUtil.getPlayer(x.DispatchedBy[ 0 ]?.SteamId);
const player = await PlayerUtil.ensurePlayer(x.DispatchedBy[ 0 ]?.SteamId);
this.emit(SimrailClientEvents.StationJoined, server, x, player);
this.stationsOccupied[ server.ServerCode ][ data.Prefix ] = {
@ -172,7 +156,7 @@ export class SimrailClient extends EventEmitter
continue;
}
// leave
const player = await PlayerUtil.getPlayer(data.DispatchedBy[ 0 ]?.SteamId);
const player = await PlayerUtil.ensurePlayer(data.DispatchedBy[ 0 ]?.SteamId);
this.emit(SimrailClientEvents.StationLeft, server, x, player, this.stationsOccupied[ server.ServerCode ][ data.Prefix ]?.JoinedAt);
delete this.stationsOccupied[ server.ServerCode ][ data.Prefix ];
@ -228,7 +212,7 @@ export class SimrailClient extends EventEmitter
// join
const date = new Date();
const player = await PlayerUtil.getPlayer(x.TrainData.ControlledBySteamID!);
const player = await PlayerUtil.ensurePlayer(x.TrainData.ControlledBySteamID!);
const playerStats = await PlayerUtil.getPlayerStats(x.TrainData.ControlledBySteamID!);
@ -250,35 +234,42 @@ export class SimrailClient extends EventEmitter
}
const date = new Date();
const player = await PlayerUtil.getPlayer(data.TrainData.ControlledBySteamID!);
const player = await PlayerUtil.ensurePlayer(data.TrainData.ControlledBySteamID!);
const playerId = data.TrainData.ControlledBySteamID!;
const trainOccupied = this.trainsOccupied[ server.ServerCode ][ data.TrainNoLocal ] && JSON.parse(JSON.stringify(this.trainsOccupied[ server.ServerCode ][ data.TrainNoLocal ])) || null;
setTimeout(() =>
{
PlayerUtil.getPlayerStats(playerId).then(playerStats =>
{
const oldKm = trainOccupied?.StartPlayerDistance ?? 0;
const distance = oldKm ? (playerStats?.stats.find(x => x.name === "DISTANCE_M")?.value ?? 0) - oldKm : 0;
let distance = oldKm ? (playerStats?.stats.find(x => x.name === "DISTANCE_M")?.value ?? 0) - oldKm : 0;
const oldPoints = trainOccupied?.StartPlayerPoints ?? 0;
const points = oldPoints ? (playerStats?.stats.find(x => x.name === "SCORE")?.value ?? 0) - oldPoints : 0;
let points = oldPoints ? (playerStats?.stats.find(x => x.name === "SCORE")?.value ?? 0) - oldPoints : 0;
if (distance < 0) {
console.warn(`Player ${playerId}, Train ${data.TrainNoLocal} - distance < 0`);
distance = 0;
}
if (points < 0) {
console.warn(`Player ${playerId}, Train ${data.TrainNoLocal} - distance < 0`);
points = 0;
}
this.emit(SimrailClientEvents.TrainLeft, server, data, player, trainOccupied?.JoinedAt, date.getTime(), points, distance, x.Vehicles[ 0 ]);
});
}, 80_000);
}, 30_000);
delete this.trainsOccupied[ server.ServerCode ][ data.TrainNoLocal ];
}
}
this.trains[ server.ServerCode ] = trains.data;
redis.json.set("trains", "$", this.trains);
redis.json.set("trains_occupied", "$", this.trainsOccupied);
@ -289,11 +280,12 @@ export class SimrailClient extends EventEmitter
private async update()
{
const servers = (await fetch("https://panel.simrail.eu:8084/servers-open").then(x => x.json()).catch(() => ({ data: [], result: false })) as ApiResponse<Server>)
.data?.filter(x => x.ServerName.includes("Polski")) ?? []; // TODO: remove this in v3
.data ?? [] //?.filter(x => x.ServerName.includes("Polski")) ?? []; // TODO: remove this in v3
if (!servers.length)
{
console.log("SimrailAPI is down");
return;
}
// TODO: maybe node:worker_threads?
@ -303,10 +295,10 @@ export class SimrailClient extends EventEmitter
const stations = (await fetch('https://panel.simrail.eu:8084/stations-open?serverCode=' + server.ServerCode).then(x => x.json()).catch(() => ({ result: false }))) as ApiResponse<Station>;
const trains = (await fetch('https://panel.simrail.eu:8084/trains-open?serverCode=' + server.ServerCode).then(x => x.json()).catch(() => ({ result: false }))) as ApiResponse<Train>;
void this.processStation(server, stations);
void this.processTrain(server, trains);
await this.processStation(server, stations);
await this.processTrain(server, trains);
}
await new Promise(res => setTimeout(res, 1000));
await this.update();
}
}

View File

@ -1,63 +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 { MSteam } from "../mongo/steam.js";
import { PlayerUtil } from "./PlayerUtil.js";
export class SteamUtil
{
public static async updatePlayerDataInDatabase(steam: string)
{
const data = await PlayerUtil.getPlayer(steam);
const stats = await PlayerUtil.getPlayerStats(steam);
const steamApi = await MSteam.findOne({ steam });
if (steamApi)
{
await MSteam.findOneAndUpdate({ steam }, { stats, personaname: data?.personaname, avatarfull: data?.avatarfull });
return;
}
await MSteam.create({ steam, stats, personaname: data?.personaname, avatarfull: data?.avatarfull });
}
public static async getPlayer(steam: string)
{
const steamApi = await MSteam.findOne({ steam });
void this.updatePlayerDataInDatabase(steam);
if (steamApi)
{
return { personname: steamApi.personaname, avatarfull: steamApi.avatarfull };
}
return await PlayerUtil.getPlayer(steam);
}
public static async getPlayerStats(steam: string)
{
const steamApi = await MSteam.findOne({ steam });
void this.updatePlayerDataInDatabase(steam);
if (steamApi)
{
return steamApi.stats;
}
return await PlayerUtil.getPlayerStats(steam);
}
}

View File

@ -28,4 +28,9 @@ export const removeProperties = <T>(data: any, names: string[]) =>
// https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
export const escapeRegexString = (str: string) => {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export const isTruthyAndGreaterThanZero = (data: number) => {
if (!data) return false;
return data > 0;
}

File diff suppressed because it is too large Load Diff

View File

@ -28,6 +28,7 @@
"react-router-dom": "^6.14.2",
"react-toastify": "^10.0.6",
"sort-by": "^0.0.2",
"swr": "^2.2.5",
"use-debounce": "^10.0.4"
},
"devDependencies": {

View File

@ -32,6 +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";
function App()
{
@ -122,6 +124,29 @@ function App()
}
/>
<Route
path="/leaderboard/steam/trains"
element={
<>
<PageMeta title="simrail.alekswilc.dev | Steam Train Leaderboard"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
<SteamTrainLeaderboard/>
</>
}
/>
<Route
path="/leaderboard/steam/stations"
element={
<>
<PageMeta title="simrail.alekswilc.dev | Steam Station Leaderboard"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
<SteamStationLeaderboard/>
</>
}
/>
<Route
path="/profile/:id"
element={

View File

@ -37,7 +37,7 @@ export const ArrowIcon = ({ rotated }: { rotated?: boolean }) =>
export const FlexArrowIcon = ({ rotated }: { rotated?: boolean }) =>
<svg
className={ `fill-current ${
rotated && "rotate-180"
!rotated && "rotate-180"
}` }
width="20"
height="20"

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 } from "react-icons/fa6";
import { FaChartSimple, FaTrain, FaBuildingFlag, FaSteam } from "react-icons/fa6";
interface SidebarProps
{
@ -219,7 +219,7 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
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" ||
pathname.includes("leaderboard")) &&
(pathname.includes("leaderboard/") && !pathname.includes('leaderboard/steam'))) &&
"bg-graydark dark:bg-meta-4"
}` }
onClick={ (e) =>
@ -271,6 +271,71 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
} }
</SidebarLinkGroup>
<SidebarLinkGroup
activeCondition={
pathname === "/leaderboard/steam" || pathname.includes("leaderboard/steam")
}
>
{ (handleClick, open) =>
{
return (
<React.Fragment>
<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")) &&
"bg-graydark dark:bg-meta-4"
}` }
onClick={ (e) =>
{
e.preventDefault();
sidebarExpanded
? handleClick()
: setSidebarExpanded(true);
} }
>
<FaSteam/>
{ t("sidebar.steam_leaderboard") }
<ArrowIcon rotated={ open }/>
</NavLink>
<div
className={ `translate transform overflow-hidden ${
!open && "hidden"
}` }
>
<ul className="mt-4 mb-5.5 flex flex-col gap-2.5 pl-6">
<li>
<NavLink
to="/leaderboard/steam/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")
}
>
<FaBuildingFlag/>
{ t("sidebar.stations") }
</NavLink>
</li>
<li>
<NavLink
to="/leaderboard/steam/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")
}
>
<FaTrain/>
{ t("sidebar.trains") }
</NavLink>
</li>
</ul>
</div>
</React.Fragment>
);
} }
</SidebarLinkGroup>
</ul>
</div>

View File

@ -19,7 +19,7 @@ import React from "react";
interface CardDataStatsProps
{
title: string;
total: string;
total: string | number;
rate?: string;
levelUp?: boolean;
levelDown?: boolean;

View File

@ -17,74 +17,69 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TLeaderboardRecord } from "../../../types/leaderboard.ts";
import { ContentLoader } from "../../mini/loaders/ContentLoader.tsx";
import { WarningAlert } from "../../mini/alerts/Warning.tsx";
import { formatTime } from "../../../util/time.ts";
import { FaCheck } from 'react-icons/fa6';
import { FaCheck } from "react-icons/fa6";
export const StationTable = ({ stations, error }: { stations: TLeaderboardRecord[], error: number }) =>
export const StationTable = ({ stations }: { stations: TLeaderboardRecord[] }) =>
{
const { t } = useTranslation();
return (
<>
{ error === 2 && <WarningAlert title={ t("content_loader.notfound.header") }
description={ t("content_loader.notfound.description") }/> }
{ error === 0 && <ContentLoader/> }
{ error === 1 && <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="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="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("leaderboard.user") }
</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") }
</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") }
</h5>
</div>
<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="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("leaderboard.user") }
</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") }
</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") }
</h5>
</div>
{ stations.map((station, key) => (
<div
className={ `grid grid-cols-2 sm:grid-cols-3 ${ stations.length === (key + 1) // todo: ...
? ""
: "border-b border-stroke dark:border-strokedark"
}` }
key={ station.id }
>
<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.steam }
className="color-orchid">{ station.steamName }</Link> { station.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.dispatcherTime) }</p>
</div>
<div className="hidden items-center justify-center p-2.5 sm:flex xl:p-5">
<Link
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") }
</Link>
</div>
</div>
)) }
</div>
</div> }
</>
{ stations.map((station, key) => (
<div
className={ `grid grid-cols-2 sm:grid-cols-3 ${ stations.length === (key + 1) // todo: ...
? ""
: "border-b border-stroke dark:border-strokedark"
}` }
key={ station.id }
>
<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") &&
<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.dispatcherTime) }</p>
</div>
<div className="hidden items-center justify-center p-2.5 sm:flex xl:p-5">
<Link
to={ "/profile/" + station.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"
>
{ t("leaderboard.profile") }
</Link>
</div>
</div>
)) }
</div>
</div>
);
};

View File

@ -17,16 +17,13 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TLeaderboardRecord } from "../../../types/leaderboard.ts";
import { ContentLoader } from "../../mini/loaders/ContentLoader.tsx";
import { WarningAlert } from "../../mini/alerts/Warning.tsx";
import { formatTime } from "../../../util/time.ts";
import { FaCheck } from "react-icons/fa6";
import { Dispatch, SetStateAction } from "react";
import { FlexArrowIcon } from "../../mini/icons/ArrowIcon.tsx";
export const TrainTable = ({ trains, error, setSortBy, sortBy }: {
export const TrainTable = ({ trains, setSortBy, sortBy }: {
trains: TLeaderboardRecord[],
error: number,
setSortBy: Dispatch<SetStateAction<string>>
sortBy: string
}) =>
@ -35,88 +32,83 @@ export const TrainTable = ({ trains, error, setSortBy, sortBy }: {
return (
<>
{ error === 2 && <WarningAlert title={ t("content_loader.notfound.header") }
description={ t("content_loader.notfound.description") }/> }
{ error === 0 && <ContentLoader/> }
{ error === 1 && <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-3 rounded-sm bg-gray-2 dark:bg-meta-4 sm:grid-cols-5">
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("leaderboard.user") }
</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") }
</h5>
<FlexArrowIcon rotated={ !(sortBy === "distance") }/>
</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") }
</h5>
<FlexArrowIcon rotated={ !(sortBy === "points") }/>
</div>
<div className="hidden sm: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("time") }>
{ t("leaderboard.time") }
</h5>
<FlexArrowIcon rotated={ !(sortBy === "time") }/>
</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") }
</h5>
</div>
<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-3 rounded-sm bg-gray-2 dark:bg-meta-4 sm:grid-cols-5">
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("leaderboard.user") }
</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") }
</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") }
</h5>
<FlexArrowIcon rotated={ sortBy === "points" }/>
</div>
<div className="hidden sm: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("time") }>
{ t("leaderboard.time") }
</h5>
<FlexArrowIcon rotated={ sortBy === "time" }/>
</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") }
</h5>
</div>
{ trains.map((train, key) => (
<div
className={ `grid grid-cols-3 sm:grid-cols-5 ${ trains.length === (key + 1)
? ""
: "border-b border-stroke dark:border-strokedark"
}` }
key={ train.id }
>
<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.steam }
className="color-orchid">{ train.steamName }</Link> { train.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.trainDistance / 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.trainPoints }</p>
</div>
<div className="hidden sm:flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-3">{ formatTime(train.trainTime) }</p>
</div>
<div className="hidden items-center justify-center p-2.5 sm:flex xl:p-5">
<Link
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"
>
{ t("leaderboard.profile") }
</Link>
</div>
</div>
)) }
</div>
</div> }
</>
);
{ trains.map((train, key) => (
<div
className={ `grid grid-cols-3 sm:grid-cols-5 ${ trains.length === (key + 1)
? ""
: "border-b border-stroke dark:border-strokedark"
}` }
key={ train.id }
>
<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") &&
<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.trainDistance / 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.trainPoints }</p>
</div>
<div className="hidden sm:flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-3">{ formatTime(train.trainTime) }</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"
>
{ t("leaderboard.profile") }
</Link>
</div>
</div>
)) }
</div>
</div>
)
;
};

View File

@ -36,7 +36,7 @@ export const StationLog = ({ data }: { data: TLogStationData }) =>
toast.info(t("log.toasts.report"), {
autoClose: 5000,
});
void navigator.clipboard.writeText(`;user: \`${ data.userUsername }\`\n;steam: \`${ data.userSteamId }\`\n;left: <t:${ Math.floor(data.leftDate / 1000) }>${ data.joinedDate ? `\n;joined: <t:${ Math.floor(data.joinedDate / 1000) }>` : "" }\n;station: \`${ data.stationName }\`\n;link: ${ location.href }\n\n`);
void navigator.clipboard.writeText(`;user: \`${ data.player.username }\`\n;steam: \`${ data.player.avatar }\`\n;left: <t:${ Math.floor(data.leftDate / 1000) }>${ data.joinedDate ? `\n;joined: <t:${ Math.floor(data.joinedDate / 1000) }>` : "" }\n;station: \`${ data.stationName }\`\n;link: ${ location.href }\n\n`);
};
return <div
@ -45,12 +45,12 @@ export const StationLog = ({ data }: { data: TLogStationData }) =>
<div
className="mx-auto w-full max-w-30 rounded-full bg-white/20 p-1 backdrop-blur sm:h-44 sm:max-w-44 sm:p-3">
<div className="relative drop-shadow-2">
<img className="rounded-full" src={ data.userAvatar } alt="profile"/>
<img className="rounded-full" src={ data.player.avatar } alt="profile"/>
</div>
</div>
<div className="mt-4">
<h3 className="mb-1.5 text-2xl font-semibold text-black dark:text-white">
{ data.userUsername }{ data.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>
@ -84,9 +84,9 @@ export const StationLog = ({ data }: { data: TLogStationData }) =>
{ t("log.buttons.copy") }
</a>
<Link
to={"/profile/" + data.userSteamId}
className="inline-flex items-center justify-center rounded-md bg-primary py-4 px-10 text-center font-medium text-white hover:bg-opacity-90 lg:px-8 xl:px-10"
>
to={"/profile/" + data.player.id}
className={ `inline-flex items-center justify-center rounded-md bg-primary py-4 px-10 text-center font-medium text-white hover:bg-opacity-90 lg:px-8 xl:px-10 ${ data.player.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ data.player.flags.includes("private") ? { pointerEvents: "none" } : undefined }>
{ t("log.buttons.profile") }
</Link>

View File

@ -37,7 +37,7 @@ export const TrainLog = ({ data }: { data: TLogTrainData }) =>
toast.info(t("log.toasts.report"), {
autoClose: 5000,
});
void navigator.clipboard.writeText(`;user: \`${ data.userUsername }\`\n;steam: \`${ data.userSteamId }\`\n;left: <t:${ Math.floor(data.leftDate / 1000) }>${ data.joinedDate ? `\n;joined: <t:${ Math.floor(data.joinedDate / 1000) }>` : "" }\n;train: \`${ data.trainNumber }\`\n;link: ${ location.href }\n\n`);
void navigator.clipboard.writeText(`;user: \`${ data.player.username }\`\n;steam: \`${ data.player.id }\`\n;left: <t:${ Math.floor(data.leftDate / 1000) }>${ data.joinedDate ? `\n;joined: <t:${ Math.floor(data.joinedDate / 1000) }>` : "" }\n;train: \`${ data.trainNumber }\`\n;link: ${ location.href }\n\n`);
};
return <div
@ -46,12 +46,12 @@ export const TrainLog = ({ data }: { data: TLogTrainData }) =>
<div
className="mx-auto w-full max-w-30 rounded-full bg-white/20 p-1 backdrop-blur sm:h-44 sm:max-w-44 sm:p-3">
<div className="relative drop-shadow-2">
<img className="rounded-full" src={ data.userAvatar } alt="profile"/>
<img className="rounded-full" src={ data.player.avatar } alt="profile"/>
</div>
</div>
<div className="mt-4">
<h3 className="mb-1.5 text-2xl font-semibold text-black dark:text-white">
{ data.userUsername } { data.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>
@ -89,8 +89,9 @@ export const TrainLog = ({ data }: { data: TLogTrainData }) =>
{ t("log.buttons.copy") }
</a>
<Link
to={ "/profile/" + data.userSteamId }
className="inline-flex items-center justify-center rounded-md bg-primary py-4 px-10 text-center font-medium text-white hover:bg-opacity-90 lg:px-8 xl:px-10"
to={ "/profile/" + data.player.id }
className={ `inline-flex items-center justify-center rounded-md bg-primary py-4 px-10 text-center font-medium text-white hover:bg-opacity-90 lg:px-8 xl:px-10 ${ data.player.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ data.player.flags.includes("private") ? { pointerEvents: "none" } : undefined }
>
{ t("log.buttons.profile") }

View File

@ -17,93 +17,89 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import dayjs from "dayjs";
import { ContentLoader } from "../../mini/loaders/ContentLoader.tsx";
import { TStationRecord } from "../../../types/station.ts";
import { WarningAlert } from "../../mini/alerts/Warning.tsx";
import { FaCheck } from 'react-icons/fa6';
import { FaCheck } from "react-icons/fa6";
// setSearchItem: Dispatch<SetStateAction<string>>
export const StationTable = ({ stations, error }: {
stations: TStationRecord[], error: number
export const StationTable = ({ stations }: {
stations: TStationRecord[]
}) =>
{
const { t } = useTranslation();
return (
<>
{ error === 2 && <WarningAlert title={ t("content_loader.notfound.header") }
description={ t("content_loader.notfound.description") }/> }
{ error === 0 && <ContentLoader/> }
{ error === 1 && <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-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("logs.user") }
</h5>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.station") }
</h5>
</div>
<div className="hidden sm:block p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.time") }
</h5>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.actions") }
</h5>
</div>
<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-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("logs.user") }
</h5>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.station") }
</h5>
</div>
<div className="hidden sm:block p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.time") }
</h5>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.actions") }
</h5>
</div>
{ stations.map((station, key) => (
<div
className={ `grid grid-cols-3 sm:grid-cols-4 ${ stations.length === (key + 1)
? ""
: "border-b border-stroke dark:border-strokedark"
}` }
key={ station.id }
>
<div className="flex items-center justify-center gap-3 p-2.5 lg:p-5">
<p className="text-black dark:text-white sm:block break-all">
<Link to={ "/profile/" + station.userSteamId }
className="color-orchid">{ station.userUsername }</Link> { station.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 sm:block break-all">{ station.server.toUpperCase() } - { station.stationName ?? "--" }</p>
</div>
<div className="hidden sm:flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-3">{ dayjs(station.leftDate).format("HH:mm DD/MM/YYYY") }</p>
</div>
<div
className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap sm:flex-nowrap ">
<Link
to={ "/profile/" + station.userSteamId }
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("logs.profile") }
</Link>
<Link
to={ "/log/" + station.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"
>
{ t("logs.record") }
</Link>
</div>
</div>
)) }
</div>
</div> }
</>
{ stations.map((station, key) => (
<div
className={ `grid grid-cols-3 sm:grid-cols-4 ${ stations.length === (key + 1)
? ""
: "border-b border-stroke dark:border-strokedark"
}` }
key={ station.id }
>
<div className="flex items-center justify-center gap-3 p-2.5 lg:p-5">
<p className="text-black dark:text-white sm:block break-all">
<Link to={ "/profile/" + (station.steam ?? station.player.id) }
className="color-orchid">{ station.username ?? station.player.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-6 sm:block break-all">{ station.server.toUpperCase() } - { station.stationName ?? "--" }</p>
</div>
<div className="hidden sm:flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-3">{ dayjs(station.leftDate).format("HH:mm DD/MM/YYYY") }</p>
</div>
<div
className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap sm:flex-nowrap ">
<Link
to={ "/profile/" + (station.steam ?? station.player.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 ${ station.player.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ station.player.flags.includes("private") ? { pointerEvents: "none" } : undefined }
>
{ t("logs.profile") }
</Link>
<Link
to={ "/log/" + station.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"
>
{ t("logs.record") }
</Link>
</div>
</div>
)) }
</div>
</div>
);
};

View File

@ -18,108 +18,103 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TTrainRecord } from "../../../types/train.ts";
import dayjs from "dayjs";
import { ContentLoader } from "../../mini/loaders/ContentLoader.tsx";
import { WarningAlert } from "../../mini/alerts/Warning.tsx";
import { FaCheck } from 'react-icons/fa6';
import { FaCheck } from "react-icons/fa6";
// setSearchItem: Dispatch<SetStateAction<string>>
export const TrainTable = ({ trains, error }: {
trains: TTrainRecord[], error: number
export const TrainTable = ({ trains }: {
trains: TTrainRecord[]
}) =>
{
const { t } = useTranslation();
return (
<>
{ error === 2 && <WarningAlert title={ t("content_loader.notfound.header") }
description={ t("content_loader.notfound.description") }/> }
{ error === 0 && <ContentLoader/> }
{ error === 1 && <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-3 rounded-sm bg-gray-2 dark:bg-meta-4 sm:grid-cols-6">
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.user") }
</h5>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.train") }
</h5>
</div>
<div className="hidden sm:block p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.points") }
</h5>
</div>
<div className="hidden sm:block p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.distance") }
</h5>
</div>
<div className="hidden sm:block p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.time") }
</h5>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.actions") }
</h5>
</div>
<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-3 rounded-sm bg-gray-2 dark:bg-meta-4 sm:grid-cols-6">
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.user") }
</h5>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.train") }
</h5>
</div>
<div className="hidden sm:block p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.points") }
</h5>
</div>
<div className="hidden sm:block p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.distance") }
</h5>
</div>
<div className="hidden sm:block p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.time") }
</h5>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.actions") }
</h5>
</div>
{ trains.map((train, key) => (
<div
className={ `grid grid-cols-3 sm:grid-cols-6 ${ trains.length === (key + 1)
? ""
: "border-b border-stroke dark:border-strokedark"
}` }
key={ train.id }
>
<div className="flex items-center justify-center gap-3 p-2.5 lg:p-5">
<p className="text-black dark:text-white sm:block break-all">
<Link to={ "/profile/" + train.userSteamId }
className="color-orchid">{ train.userUsername }</Link> { train.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 sm:block break-all">{ train.server.toUpperCase() } - { train.trainNumber ?? "--" }</p>
</div>
<div className="hidden sm:flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-6">{ train.distance ? train.points : "--" }</p>
</div>
<div className="hidden sm:flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-5">{ train.distance ? `${ (train.distance / 1000).toFixed(2) }km` : "--" }</p>
</div>
<div className="hidden sm:flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-3">{ dayjs(train.leftDate).format("HH:mm DD/MM/YYYY") }</p>
</div>
<div
className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap sm:flex-nowrap ">
<Link
to={ "/profile/" + train.userSteamId }
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("logs.profile") }
</Link>
<Link
to={ "/log/" + 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"
>
{ t("logs.record") }
</Link>
</div>
</div>
)) }
</div>
</div> }
</>
{ trains.map((train, key) => (
<div
className={ `grid grid-cols-3 sm:grid-cols-6 ${ trains.length === (key + 1)
? ""
: "border-b border-stroke dark:border-strokedark"
}` }
key={ train.id }
>
<div className="flex items-center justify-center gap-3 p-2.5 lg:p-5">
<p className="text-black dark:text-white sm:block break-all">
<Link to={ "/profile/" + (train.steam ?? train.player.id) }
className="color-orchid">{ train.username ?? train.player.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 sm:block break-all">{ train.server.toUpperCase() } - { train.trainNumber ?? "--" }</p>
</div>
<div className="hidden sm:flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-6">{ train.distance ? train.points : "--" }</p>
</div>
<div className="hidden sm:flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-5">{ train.distance ? `${ (train.distance / 1000).toFixed(2) }km` : "--" }</p>
</div>
<div className="hidden sm:flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-3">{ dayjs(train.leftDate).format("HH:mm DD/MM/YYYY") }</p>
</div>
<div
className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap sm:flex-nowrap ">
<Link
to={ "/profile/" + (train.steam ?? train.player.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 ${ train.player.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ train.player.flags.includes("private") ? { pointerEvents: "none" } : undefined }
>
{ t("logs.profile") }
</Link>
<Link
to={ "/log/" + 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"
>
{ t("logs.record") }
</Link>
</div>
</div>
)) }
</div>
</div>
);
};

View File

@ -35,12 +35,12 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
<div
className="mx-auto w-full max-w-30 rounded-full bg-white/20 p-1 backdrop-blur sm:h-44 sm:max-w-44 sm:p-3">
<div className="relative drop-shadow-2">
<img className="rounded-full" src={ data.steam.avatarfull } alt="profile"/>
<img className="rounded-full" src={ data.player.avatar } alt="profile"/>
</div>
</div>
<div className="mt-4">
<h3 className="mb-1.5 text-2xl font-semibold text-black dark:text-white">
{ data.steam.personname } { data.player.verified &&
{ data.player.username } { data.player.flags.includes('verified') &&
<FaCheck className={ "inline text-meta-3 ml-1" }/> }
</h3>
@ -83,21 +83,21 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("profile.trains.distance") }
</h5>
<FlexArrowIcon rotated={ !(sortTrainsBy === "distance") }/>
<FlexArrowIcon rotated={ sortTrainsBy === "distance" || !sortTrainsBy }/>
</div>
<div className="flex flex-row align-center justify-center gap-2 p-2.5 text-center xl:p-5 cursor-pointer"
onClick={ () => setSortTrainsBy("score") }>
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("profile.trains.points") }
</h5>
<FlexArrowIcon rotated={ !(sortTrainsBy === "score") }/>
<FlexArrowIcon rotated={ sortTrainsBy === "score" }/>
</div>
<div className="hidden sm:flex flex-row align-center justify-center gap-2 p-2.5 text-center xl:p-5 cursor-pointer"
onClick={ () => setSortTrainsBy("time") }>
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("profile.trains.time") }
</h5>
<FlexArrowIcon rotated={ !(sortTrainsBy === "time") }/>
<FlexArrowIcon rotated={ sortTrainsBy === "time" }/>
</div>
</div>
@ -107,7 +107,7 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
return <div
className={ `grid grid-cols-3 sm:grid-cols-4 border-t border-t-stroke dark:border-t-strokedark` }
key={ 1 }
key={ trainName }
>
<div className="flex items-center justify-center gap-3 p-2.5 lg:p-5">
<p className="text-black dark:text-white sm:block break-all">
@ -137,7 +137,7 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
<div className="bg-white px-5 pt-6 pb-5 shadow-default dark:bg-boxdark sm:px-7.5">
<div className="group relative cursor-pointer" onClick={ () => setShowStations(val => !val) }>
<h1 className="text-xl text-black dark:text-white pb-5">{ t("profile.stations.header") }</h1>
<ArrowIcon rotated={ showTrains }/>
<ArrowIcon rotated={ showStations }/>
</div>
{ showStations &&
<div className="flex flex-col rounded-sm border border-stroke dark:border-strokedark">
@ -159,7 +159,7 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
const station = data.player.dispatcherStats[ stationName ];
return <div
className={ `grid grid-cols-2 border-t border-t-stroke dark:border-t-strokedark` }
key={ 1 }
key={ stationName }
>
<div className="flex items-center justify-center gap-3 p-2.5 lg:p-5">
<p className="text-black dark:text-white sm:block break-all">

View File

@ -0,0 +1,85 @@
/*
* 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 { 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";
export const SteamStationTable = ({ stations }: { stations: TLeaderboardRecord[] }) =>
{
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="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("leaderboard.user") }
</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") }
</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") }
</h5>
</div>
</div>
{ stations.map((station, key) => (
<div
className={ `grid grid-cols-2 sm:grid-cols-3 ${ stations.length === (key + 1) // todo: ...
? ""
: "border-b border-stroke dark:border-strokedark"
}` }
key={ station.id }
>
<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") &&
<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>
</div>
<div className="hidden items-center justify-center p-2.5 sm:flex xl:p-5">
<Link
to={ "/profile/" + station.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"
>
{ t("leaderboard.profile") }
</Link>
</div>
</div>
)) }
</div>
</div>
);
};

View File

@ -0,0 +1,102 @@
/*
* 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 { 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
}) =>
{
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-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") }
</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") }
</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") }
</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") }
</h5>
</div>
</div>
{ trains.map((train, key) => (
<div
className={ `grid grid-cols-3 sm:grid-cols-4 ${ trains.length === (key + 1)
? ""
: "border-b border-stroke dark:border-strokedark"
}` }
key={ train.id }
>
<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") &&
<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>
</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"
>
{ t("leaderboard.profile") }
</Link>
</div>
</div>
)) }
</div>
</div>
)
;
};

View File

@ -133,6 +133,7 @@
"stations": "Stations",
"trains": "Trains",
"leaderboard": "Leaderboard",
"steam_leaderboard": "Steam leaderboard",
"info": "INFO",
"admin": "ADMIN"
},

View File

@ -133,6 +133,7 @@
"stations": "Stacje",
"trains": "Pociągi",
"leaderboard": "Tablica wyników",
"steam_leaderboard": "Tablica wyników steam",
"info": "INFO",
"admin": "ADMIN"
},

View File

@ -14,49 +14,33 @@
* See LICENSE for more.
*/
import React, { useEffect, useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import { TStatsResponse } from "../types/stats.ts";
import { WarningAlert } from "../components/mini/alerts/Warning.tsx";
import { CardDataStats } from "../components/mini/util/CardDataStats.tsx";
import { fetcher } from "../util/fetcher.ts";
import useSWR from 'swr';
import { LoadError } from "../components/mini/loaders/ContentLoader.tsx";
export const Home: React.FC = () =>
export const Home = () =>
{
const { t } = useTranslation();
const [ commit, setCommit ] = useState("");
const [ version, setVersion ] = useState("");
const [ trains, setTrains ] = useState(0);
const [ dispatchers, setDispatchers ] = useState(0);
const [ profiles, setProfiles ] = useState(0);
useEffect(() =>
{
fetch(`${ import.meta.env.VITE_API_URL }/stats/`).then(x => x.json()).then((data: TStatsResponse) =>
{
data.data.git.commit && setCommit(data.data.git.commit);
data.data.git.version && setVersion(data.data.git.version);
// ADD ALERT IF API DOESN'T WORK! toast?
setTrains(data.data.stats.trains);
setDispatchers(data.data.stats.dispatchers);
setProfiles(data.data.stats.profiles);
});
}, []);
const { data, error } = useSWR<TStatsResponse>("/stats/", fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
return (
<>
<div className="flex pb-5">
<WarningAlert description={ t("preview.description") } title={ t("preview.title") }/>
{ error && <LoadError /> }
</div>
<div className="flex flex-col gap-10">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-6 xl:grid-cols-3 2xl:gap-7.5">
<CardDataStats title={ t("home.stats.trains") } total={ trains.toString() }/>
<CardDataStats title={ t("home.stats.dispatchers") } total={ dispatchers.toString() }/>
<CardDataStats title={ t("home.stats.profiles") } total={ profiles.toString() }/>
<CardDataStats title={ t("home.stats.trains") } total={ data?.data?.stats?.trains ?? "-" }/>
<CardDataStats title={ t("home.stats.dispatchers") } total={ data?.data?.stats?.dispatchers ?? "-" }/>
<CardDataStats title={ t("home.stats.profiles") } total={ data?.data?.stats?.profiles ?? "-" }/>
</div>
@ -108,16 +92,16 @@ export const Home: React.FC = () =>
} }
/></p>
<p>{ t("home.footer.license") } <a className="color-orchid"
href={ "/LICENSE.txt" }>GNU
href={ "/LICENSE.txt" }>GNU
AGPL V3</a></p>
<p>{ t("home.footer.powered") } <Link className="color-orchid"
to={ "https://tailadmin.com/" }>TailAdmin</Link>
</p>
<p>{ version && <Link className="color-orchid"
to={ `https://git.alekswilc.dev/simrail/simrail.alekswilc.dev/releases/tag/${ version }` }>{ version }</Link> }{ version && commit && " | " }{ commit &&
<p>{ data?.data?.git?.version && <Link className="color-orchid"
to={ `https://git.alekswilc.dev/simrail/simrail.alekswilc.dev/releases/tag/${ data?.data?.git?.version }` }>{ data?.data?.git?.version }</Link> }{ data?.data?.git?.version && data?.data?.git?.commit && " | " }{ data?.data?.git?.commit &&
<Link className="color-orchid"
to={ `https://git.alekswilc.dev/simrail/simrail.alekswilc.dev/commit/${ commit }` }>{ commit }</Link> }</p>
to={ `https://git.alekswilc.dev/simrail/simrail.alekswilc.dev/commit/${ data?.data?.git?.commit }` }>{ data?.data?.git?.commit }</Link> }</p>
</div>

View File

@ -14,42 +14,39 @@
* See LICENSE for more.
*/
import { TLeaderboardRecord } from "../../types/leaderboard.ts";
import { ChangeEvent, useEffect, useState } from "react";
import { StationTable } from "../../components/pages/leaderboard/StationTable.tsx";
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 { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
import { useTranslation } from "react-i18next";
export const StationLeaderboard = () =>
{
const [ data, setData ] = useState<TLeaderboardRecord[]>([]);
const [ params, setParams ] = useState(new URLSearchParams());
const { data, error, isLoading } = useSWR(`/leaderboard/station/?${params.toString()}`, fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchParams, setSearchParams ] = useSearchParams();
const [ searchItem, setSearchItem ] = useState(searchParams.get("q") ?? "");
useEffect(() =>
{
fetch(`${ import.meta.env.VITE_API_URL }leaderboard/station/`).then(x => x.json()).then(x =>
{
setData(x.data.records);
});
}, []);
const [ searchValue ] = useDebounce(searchItem, 500);
const [ error, setError ] = useState<0 | 1 | 2>(0);
useEffect(() =>
{
searchValue === "" ? searchParams.delete("q") : searchParams.set("q", searchValue);
setSearchParams(searchParams);
setData([]);
setError(0);
fetch(`${ import.meta.env.VITE_API_URL }/leaderboard/station/?q=${ searchValue }`).then(x => x.json()).then(x =>
{
setData(x.data.records);
setError(x.data.records.length > 0 ? 1 : 2);
});
const params = new URLSearchParams();
searchValue && params.set("q", searchValue);
setSearchParams(params.toString());
setParams(params);
}, [ searchValue ]);
useEffect(() =>
@ -62,11 +59,23 @@ export const StationLeaderboard = () =>
setSearchItem(e.target.value);
};
const { t } = useTranslation();
return (
<>
<div className="flex flex-col gap-10">
<Search handleInputChange={ handleInputChange } searchItem={ searchItem }/>
<StationTable stations={ data } error={ error }/>
<>
{ 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.code === 200 && data.data && !!data?.data?.records?.length && <StationTable stations={ data.data.records } /> }
</>
</div>
</>
);

View File

@ -14,49 +14,41 @@
* See LICENSE for more.
*/
import { TLeaderboardRecord } from "../../types/leaderboard.ts";
import { ChangeEvent, useEffect, useState } from "react";
import { TrainTable } from "../../components/pages/leaderboard/TrainTable.tsx";
import { useDebounce } from "use-debounce";
import { Search } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
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";
export const TrainLeaderboard = () =>
{
const [ data, setData ] = useState<TLeaderboardRecord[]>([]);
const [ params, setParams ] = useState(new URLSearchParams());
const { data, error, isLoading } = useSWR(`/leaderboard/train/?${ params.toString() }`, fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchParams, setSearchParams ] = useSearchParams();
const [ searchItem, setSearchItem ] = useState(searchParams.get("q") ?? "");
const [ sortBy, setSortBy ] = useState(searchParams.get("distance") ?? "");
useEffect(() =>
{
fetch(`${ import.meta.env.VITE_API_URL }/leaderboard/train/`).then(x => x.json()).then(x =>
{
setData(x.data.records);
});
}, []);
const [ sortBy, setSortBy ] = useState("distance");
const [ searchValue ] = useDebounce(searchItem, 500);
const [ error, setError ] = useState<0 | 1 | 2>(0);
useEffect(() =>
{
searchValue === "" ? searchParams.delete("q") : searchParams.set("q", searchValue);
setSearchParams(searchParams);
const params = new URLSearchParams();
searchValue && params.set("q", searchValue);
sortBy && params.set("s", sortBy);
searchValue && params.set('q', searchValue);
sortBy && params.set('s', sortBy);
setData([]);
setError(0);
fetch(`${ import.meta.env.VITE_API_URL }/leaderboard/train/?${params.toString()}`).then(x => x.json()).then(x =>
{
setData(x.data.records);
setError(x.data.records.length > 0 ? 1 : 2);
});
setSearchParams(params.toString());
setParams(params);
}, [ searchValue, sortBy ]);
useEffect(() =>
@ -69,12 +61,23 @@ export const TrainLeaderboard = () =>
setSearchItem(e.target.value);
};
const { t } = useTranslation();
return (
<>
<div className="flex flex-col gap-10">
<Search handleInputChange={ handleInputChange } searchItem={ searchItem }/>
<TrainTable trains={ data } error={ error } setSortBy={setSortBy} sortBy={sortBy}/>
<>
{ error && <LoadError/> }
{ isLoading && <ContentLoader/> }
{ (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?.records?.length && <TrainTable trains={ data?.data?.records } setSortBy={ setSortBy } sortBy={ sortBy }/> }
</>
</div>
</>
);

View File

@ -14,74 +14,52 @@
* See LICENSE for more.
*/
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { ContentLoader } from "../../components/mini/loaders/ContentLoader.tsx";
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
import { useTranslation } from "react-i18next";
import { TLogResponse, TLogStationData, TLogTrainData } from "../../types/log.ts";
import { StationLog } from "../../components/pages/log/StationLog.tsx";
import { TrainLog } from "../../components/pages/log/TrainLog.tsx";
import { PageMeta } from "../../components/mini/util/PageMeta.tsx";
import { fetcher } from "../../util/fetcher.ts";
import useSWR from "swr";
export const Log = () =>
{
const { id } = useParams();
const [ error, setError ] = useState<0 | 1 | 2 | 3>(0);
const [ trainData, setTrainData ] = useState<TLogTrainData>(undefined!);
const [ stationData, setStationData ] = useState<TLogStationData>(undefined!);
useEffect(() =>
{
fetch(`${ import.meta.env.VITE_API_URL }/log/${ id }`).then(x => x.json()).then((data: TLogResponse) =>
{
switch (data.code)
{
case 404:
setError(2);
break;
case 403:
// NOT_IMPLEMENTED
setError(3);
break;
case 200:
setError(1);
"trainNumber" in data.data ? setTrainData(data.data) : setStationData(data.data);
break;
}
});
}, []);
const { data, error, isLoading } = useSWR(`/log/${ id }`, fetcher, { refreshInterval: 30_000, errorRetryCount: 5 });
const { t } = useTranslation();
return (
<>
{/* ERROR */}
{ error && <LoadError /> }
{/* LOADING */ }
{ error === 0 && <ContentLoader/> }
{ isLoading && <ContentLoader/> }
{/* NOT FOUND */ }
{ error === 2 && <PageMeta title="simrail.alekswilc.dev | Record not found"
description="This record could not be found."/> }
{ error === 2 && <WarningAlert title={ t("log.errors.notfound.title") }
description={ t("log.errors.notfound.description") }/> }
{ data && data.code === 404 && <PageMeta title="simrail.alekswilc.dev | Record not found"
description="This record could not be found."/> }
{ data && data.code === 404 && <WarningAlert title={ t("log.errors.notfound.title") }
description={ t("log.errors.notfound.description") }/> }
{/* BLACKLISTED LOG */ }
{ error === 3 && <PageMeta title="simrail.alekswilc.dev | Blacklisted record"
description="The record has been blocked."/> }
{ error === 3 && <WarningAlert title={ t("log.errors.blacklist.title") }
description={ t("log.errors.blacklist.description") }/> }
{ data && data.code === 403 && <PageMeta title="simrail.alekswilc.dev | Blacklisted record"
description="The record has been blocked."/> }
{ data && data.code === 403 && <WarningAlert title={ t("log.errors.blacklist.title") }
description={ t("log.errors.blacklist.description") }/> }
{/* SUCCESS */ }
{ error === 1 && stationData && <PageMeta
title={ `simrail.alekswilc.dev | ${ stationData.userUsername }` }
image={ stationData.userAvatar }
description={ `${ stationData.stationName } - ${ stationData.stationShort }` }/> }
{ error === 1 && stationData && < StationLog data={ stationData }/> }
{ data && data.code === 200 && !("trainNumber" in data.data) && <PageMeta
title={ `simrail.alekswilc.dev | ${ data.data.player.username }` }
image={ data.data.player.avatar }
description={ `${ data.data.stationName } - ${ data.data.stationShort }` }/> }
{ data && data.code === 200 && !("trainNumber" in data.data) && data.data &&
< StationLog data={ data.data }/> }
{ error === 1 && trainData && <PageMeta
title={ `simrail.alekswilc.dev | ${ trainData.userUsername }` }
image={ trainData.userAvatar }
description={ `${ trainData.trainName } - ${ trainData.trainNumber }` }/> }
{ error === 1 && trainData && < TrainLog data={ trainData }/> }
{ data && data.code === 200 && ("trainNumber" in data.data) && <PageMeta
title={ `simrail.alekswilc.dev | ${ data.data.player.username }` }
image={ data.data.player.avatar }
description={ `${ data.data.trainName } - ${ data.data.trainNumber }` }/> }
{ data && data.code === 200 && ("trainNumber" in data.data) && < TrainLog data={ data.data }/> }
</>
);
};

View File

@ -17,37 +17,33 @@
import { ChangeEvent, useEffect, useState } from "react";
import { StationTable } from "../../components/pages/logs/StationTable.tsx";
import { useDebounce } from "use-debounce";
import { TStationRecord } from "../../types/station.ts";
import { Search } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import useSWR from 'swr';
import { fetcher } from "../../util/fetcher.ts";
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
import { useTranslation } from 'react-i18next';
export const StationLogs = () =>
{
const [ data, setData ] = useState<TStationRecord[]>([]);
const [params, setParams] = useState(new URLSearchParams());
const { data, error, isLoading } = useSWR(`/stations/?${params.toString()}`, fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchParams, setSearchParams ] = useSearchParams();
const [ searchItem, setSearchItem ] = useState(searchParams.get("q") ?? "");
useEffect(() =>
{
fetch(`${ import.meta.env.VITE_API_URL }/stations/`).then(x => x.json()).then(x =>
{
setData(x.data.records);
});
}, []);
const [ error, setError ] = useState<0 | 1 | 2>(0);
const [ searchValue ] = useDebounce(searchItem, 500);
useEffect(() =>
{
searchValue === "" ? searchParams.delete("q") : searchParams.set("q", searchValue);
setSearchParams(searchParams);
setData([]);
setError(0);
fetch(`${ import.meta.env.VITE_API_URL }/stations/?q=${ searchValue }`).then(x => x.json()).then(x =>
{
setData(x.data.records);
setError(x.data.records.length > 0 ? 1 : 2);
});
const params = new URLSearchParams();
searchValue && params.set('q', searchValue);
setSearchParams(params.toString());
setParams(params);
}, [ searchValue ]);
useEffect(() =>
@ -60,11 +56,22 @@ export const StationLogs = () =>
setSearchItem(e.target.value);
};
const { t } = useTranslation();
return (
<>
<div className="flex flex-col gap-10">
<Search handleInputChange={ handleInputChange } searchItem={ searchItem }/>
<StationTable stations={ data } error={ error }/>
<>
{ error && <LoadError /> }
{isLoading && <ContentLoader/> }
{ (data && data.code === 404) || (data && data.code === 200 && !data?.data?.records?.length) && <WarningAlert title={ t("content_loader.notfound.header") }
description={ t("content_loader.notfound.description") }/> }
{data && data.code === 200 && !!data?.data?.records?.length && <StationTable stations={data.data.records} /> }
</>
</div>
</>
);

View File

@ -15,41 +15,35 @@
*/
import { ChangeEvent, useEffect, useState } from "react";
import { TTrainRecord } from "../../types/train.ts";
import { TrainTable } from "../../components/pages/logs/TrainTable.tsx";
import { useDebounce } from "use-debounce";
import { Search } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
import { useTranslation } from "react-i18next";
import { fetcher } from "../../util/fetcher.ts";
import useSWR from "swr";
export const TrainLogs = () =>
{
const [ data, setData ] = useState<TTrainRecord[]>([]);
const [ params, setParams ] = useState(new URLSearchParams());
const { data, error, isLoading } = useSWR(`/trains/?${ params.toString() }`, fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchParams, setSearchParams ] = useSearchParams();
const [ searchItem, setSearchItem ] = useState(searchParams.get("q") ?? "");
useEffect(() =>
{
fetch(`${ import.meta.env.VITE_API_URL }/trains/`).then(x => x.json()).then(x =>
{
setData(x.data.records);
});
}, []);
const [ error, setError ] = useState<0 | 1 | 2>(0);
const [ searchValue ] = useDebounce(searchItem, 500);
useEffect(() =>
{
searchValue === "" ? searchParams.delete("q") : searchParams.set("q", searchValue);
setSearchParams(searchParams);
setData([]);
setError(0);
fetch(`${ import.meta.env.VITE_API_URL }/trains/?q=${ searchValue }`).then(x => x.json()).then(x =>
{
setData(x.data.records);
setError(x.data.records.length > 0 ? 1 : 2);
});
const params = new URLSearchParams();
searchValue && params.set("q", searchValue);
setSearchParams(params.toString());
setParams(params);
}, [ searchValue ]);
useEffect(() =>
@ -62,11 +56,22 @@ export const TrainLogs = () =>
setSearchItem(e.target.value);
};
const { t } = useTranslation();
return (
<>
<div className="flex flex-col gap-10">
<Search handleInputChange={ handleInputChange } searchItem={ searchItem }/>
<TrainTable trains={ data } error={ error }/>
<>
{ error && <LoadError/> }
{ isLoading && <ContentLoader/> }
{ (data && data.code === 404) || (data && data.code === 200 && !data?.data?.records?.length) && <WarningAlert title={ t("content_loader.notfound.header") }
description={ t("content_loader.notfound.description") }/> }
{ data && data.code === 200 && !!data?.data?.records?.length && <TrainTable trains={ data.data.records }/> }
</>
</div>
</>
);

View File

@ -14,64 +14,44 @@
* See LICENSE for more.
*/
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { TProfileData, TProfileResponse } from "../../types/profile.ts";
import { ContentLoader } from "../../components/mini/loaders/ContentLoader.tsx";
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
import { ProfileCard } from "../../components/pages/profile/Profile.tsx";
import { useTranslation } from "react-i18next";
import { PageMeta } from "../../components/mini/util/PageMeta.tsx";
import { formatTime } from "../../util/time.ts";
import useSWR from 'swr';
import { fetcher } from "../../util/fetcher.ts";
export const Profile = () =>
{
const { id } = useParams();
const [ error, setError ] = useState<0 | 1 | 2 | 3>(0);
const [ data, setData ] = useState<TProfileData>(undefined!);
useEffect(() =>
{
fetch(`${ import.meta.env.VITE_API_URL }/profiles/${ id }`).then(x => x.json()).then((data: TProfileResponse) =>
{
switch (data.code)
{
case 404:
setError(2);
break;
case 403:
setError(3);
break;
case 200:
setError(1);
setData(data.data);
break;
}
});
}, []);
const { data, error, isLoading } = useSWR(`/profiles/${ id }`, fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
const { t } = useTranslation();
return (
<>
{/* LOADING */ }
{ error === 0 && <ContentLoader/> }
{ isLoading && <ContentLoader/> }
{/* ERROR */}
{ error && <LoadError /> }
{/* NOT FOUND */ }
{ error === 2 && <PageMeta title="simrail.alekswilc.dev | Profile not found"
{ data && data.code === 404 && <PageMeta title="simrail.alekswilc.dev | Profile not found"
description="Player's profile could not be found or the player has a private Steam profile."/> }
{ error === 2 && <WarningAlert title={ t("profile.errors.notfound.title") }
{ data && data.code === 404 && <WarningAlert title={ t("profile.errors.notfound.title") }
description={ t("profile.errors.notfound.description") }/> }
{/* BLACKLISTED PROFILE */ }
{ error === 3 && <PageMeta title="simrail.alekswilc.dev | Blacklisted profile"
description="This player's profile has been blocked."/> }
{ error === 3 && <WarningAlert title={ t("profile.errors.blacklist.title") }
description={ t("profile.errors.blacklist.description") }/> }
{/* SUCCESS */ }
{ error === 1 && <PageMeta image={ data.steam.avatarfull }
title={ `simrail.alekswilc.dev | ${ data.steam.personname }'s profile` }
description={ `${ data.player.trainDistance ? 0 : ((data.player.trainDistance / 1000).toFixed(2)) } driving experience |
${ data.player.dispatcherTime ? 0 : formatTime(data.player.dispatcherTime) } dispatcher experience` }/> }
{ error === 1 && <ProfileCard data={ data }/> }
{ data && data.code === 200 && <PageMeta image={ data.data.player.username }
title={ `simrail.alekswilc.dev | ${ data.data.player.username }'s profile` }
description={ `${ data.data.player.trainDistance ? 0 : ((data.data.player.trainDistance / 1000).toFixed(2)) } driving experience |
${ data.data.player.dispatcherTime ? 0 : formatTime(data.data.player.dispatcherTime) } dispatcher experience` }/> }
{ data && data.code === 200 && <ProfileCard data={ data.data }/> }
</>
);
};

View File

@ -0,0 +1,82 @@
/*
* 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 { ChangeEvent, useEffect, useState } from "react";
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 { 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";
export const SteamStationLeaderboard = () =>
{
const [ params, setParams ] = useState(new URLSearchParams());
const { data, error, isLoading } = useSWR(`/steam/leaderboard/station/?${params.toString()}`, fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchParams, setSearchParams ] = useSearchParams();
const [ searchItem, setSearchItem ] = useState(searchParams.get("q") ?? "");
const [ searchValue ] = useDebounce(searchItem, 500);
useEffect(() =>
{
searchValue === "" ? searchParams.delete("q") : searchParams.set("q", searchValue);
const params = new URLSearchParams();
searchValue && params.set("q", searchValue);
setSearchParams(params.toString());
setParams(params);
}, [ searchValue ]);
useEffect(() =>
{
setSearchItem(searchParams.get("q") ?? "");
}, [ searchParams ]);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) =>
{
setSearchItem(e.target.value);
};
const { t } = useTranslation();
return (
<>
<div className="flex flex-col gap-10">
<Search handleInputChange={ handleInputChange } searchItem={ searchItem }/>
<>
{ 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.code === 200 && data.data && !!data?.data?.records?.length && <SteamStationTable stations={ data.data.records } /> }
</>
</div>
</>
);
};

View File

@ -0,0 +1,84 @@
/*
* 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 { ChangeEvent, useEffect, useState } from "react";
import { useDebounce } from "use-debounce";
import { Search } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
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";
export const SteamTrainLeaderboard = () =>
{
const [ params, setParams ] = useState(new URLSearchParams());
const { data, error, isLoading } = useSWR(`/steam/leaderboard/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(() =>
{
searchValue === "" ? searchParams.delete("q") : searchParams.set("q", searchValue);
const params = new URLSearchParams();
searchValue && params.set("q", searchValue);
sortBy && params.set("s", sortBy);
setSearchParams(params.toString());
setParams(params);
}, [ searchValue, sortBy ]);
useEffect(() =>
{
setSearchItem(searchParams.get("q") ?? "");
}, [ searchParams ]);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) =>
{
setSearchItem(e.target.value);
};
const { t } = useTranslation();
return (
<>
<div className="flex flex-col gap-10">
<Search handleInputChange={ handleInputChange } searchItem={ searchItem }/>
<>
{ error && <LoadError/> }
{ isLoading && <ContentLoader/> }
{ (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?.records?.length && <SteamTrainTable trains={ data?.data?.records } setSortBy={ setSortBy } sortBy={ sortBy }/> }
</>
</div>
</>
);
};

View File

@ -28,16 +28,17 @@ export interface TLeaderboardData
export interface TLeaderboardRecord
{
id: string;
steam: string;
steamName: string;
trainTime: number;
trainPoints: number;
trainDistance: number;
dispatcherTime: number;
dispatcherStats?: { [ key: string ]: TLeaderboardDispatcherStat };
trainStats?: { [ key: string ]: TLeaderboardTrainStat };
verified: boolean;
"id": string,
"username": string,
"avatar": string,
"trainTime": number,
"trainPoints": number,
"trainDistance": number,
"dispatcherTime": number,
"steamDispatcherTime": number,
"steamTrainDistance": number,
"steamTrainScore": number,
"flags": string[]
}
export interface TLeaderboardDispatcherStat

View File

@ -14,6 +14,8 @@
* See LICENSE for more.
*/
import { TProfilePlayer } from "./profile.ts";
export interface TLogResponse
{
success: boolean;
@ -23,31 +25,25 @@ export interface TLogResponse
export interface TLogTrainData
{
id: string;
trainNumber: string;
userSteamId: string;
userUsername: string;
userAvatar: string;
leftDate: number;
distance?: number;
points?: number;
server: string;
trainName: string;
joinedDate?: number;
verified: boolean;
"id": string,
"trainNumber": string,
"leftDate": number,
"joinedDate": number,
"distance": number,
"points": number,
"server": string,
"trainName": string,
player: TProfilePlayer
}
export interface TLogStationData
{
id: string;
userSteamId: string;
userUsername: string;
userAvatar: string;
leftDate: number;
stationName: string;
stationShort: string;
server: string;
joinedDate?: number;
verified: boolean;
"id": string,
"joinedDate": number,
"leftDate": number,
"stationName": string,
"stationShort": string,
"server": string,
player: TProfilePlayer
}

View File

@ -33,58 +33,29 @@ export interface TProfileSuccessResponse
export interface TProfileData
{
player: TProfilePlayer;
steam: TProfileSteam;
steamStats: TProfileSteamStats;
}
export interface TProfilePlayer
{
id: string;
steam: string;
steamName: string;
trainTime: number;
dispatcherTime: number;
dispatcherStats: Record<string, TProfileDispatcherStatsRecord>;
trainStats: Record<string, TProfileTrainStatsRecord>;
trainDistance: number;
trainPoints: number;
verified: boolean;
}
"id": string,
"username": string,
"avatar": string,
"trainTime": number,
"trainPoints": number,
"trainDistance": number,
"dispatcherTime": number,
"steamDispatcherTime": number,
"steamTrainDistance": number,
"steamTrainScore": number,
"flags": string[]
export interface TProfileDispatcherStatsRecord
{
time: number;
}
trainStats: Record<string, {
time: number;
score: number;
distance: number;
}>
export interface TProfileTrainStatsRecord
{
distance: number;
score: number;
time: number;
}
export interface TProfileSteam
{
personname: string;
avatarfull: string;
}
export interface TProfileSteamStats
{
steamID: string;
gameName: string;
stats: TProfileSteamStatsAchievementStat[];
achievements: TProfileSteamStatsAchievement[];
}
export interface TProfileSteamStatsAchievement
{
name: string;
achieved: number;
}
export interface TProfileSteamStatsAchievementStat
{
name: string;
value: number;
}
dispatcherStats: Record<string, {
time: number;
}>
}

View File

@ -14,6 +14,8 @@
* See LICENSE for more.
*/
import { TProfilePlayer } from "./profile.ts";
export interface TStationResponse
{
success: boolean;
@ -28,14 +30,16 @@ export interface TStationData
export interface TStationRecord
{
id: string;
userSteamId: string;
userUsername: string;
userAvatar: string;
leftDate: number;
stationName: string;
stationShort: string;
server: string;
joinedDate?: number;
verified: boolean;
"id": string,
"joinedDate": number,
"leftDate": number,
"stationName": string,
"stationShort": string,
"server": string,
username: string
steam: string;
player: TProfilePlayer
}

View File

@ -14,6 +14,8 @@
* See LICENSE for more.
*/
import { TProfilePlayer } from "./profile.ts";
export interface TTrainResponse
{
success: boolean;
@ -28,17 +30,17 @@ export interface TTrainData
export interface TTrainRecord
{
id: string;
trainNumber: string;
userSteamId: string;
userUsername: string;
userAvatar: string;
joinedDate?: number;
leftDate: number;
distance: number;
points: number;
server: string;
trainName: string;
verified: boolean;
"id": string,
"trainNumber": string,
"joinedDate": number,
"leftDate": number,
"distance": number,
"points": number,
"server": string,
"trainName": string,
username: string
steam: string;
player: TProfilePlayer
}

View File

@ -14,28 +14,4 @@
* See LICENSE for more.
*/
import { Model, model, Schema } from "mongoose";
export const raw_schema = {
steam: {
type: String,
required: true,
},
status: {
type: Boolean,
default: false,
},
};
const schema = new Schema<IBlacklist>(raw_schema);
export type TMBlacklist = Model<IBlacklist>
export const MBlacklist = model<IBlacklist>("blacklist", schema);
export interface IBlacklist
{
steam: string;
status: boolean;
}
export const fetcher = (url: string) => fetch(`${ import.meta.env.VITE_API_URL }${url}`, { signal: AbortSignal.timeout(2500) }).then((res) => res.json());