forked from simrail/simrail.pro
feat(*): use SWR, add steam leaderboard, rewrite SimrailClient.ts, update DB structure.
This commit is contained in:
parent
0a3c70b108
commit
4ec78b998d
3
packages/backend/.gitignore
vendored
Normal file
3
packages/backend/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
@ -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"
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
}));
|
||||
});
|
||||
|
||||
|
@ -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(),
|
||||
);
|
||||
|
@ -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(),
|
||||
);
|
||||
});
|
||||
|
@ -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(
|
||||
|
100
packages/backend/src/http/routes/steamLeaderboard.ts
Normal file
100
packages/backend/src/http/routes/steamLeaderboard.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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(),
|
||||
);
|
||||
|
@ -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());
|
||||
|
||||
|
@ -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 }`);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -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[]
|
||||
}
|
@ -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;
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
3560
packages/backend/yarn-error.log
Normal file
3560
packages/backend/yarn-error.log
Normal file
File diff suppressed because it is too large
Load Diff
@ -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": {
|
||||
|
@ -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={
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
||||
|
@ -19,7 +19,7 @@ import React from "react";
|
||||
interface CardDataStatsProps
|
||||
{
|
||||
title: string;
|
||||
total: string;
|
||||
total: string | number;
|
||||
rate?: string;
|
||||
levelUp?: boolean;
|
||||
levelDown?: boolean;
|
||||
|
@ -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>
|
||||
|
||||
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
)
|
||||
;
|
||||
};
|
@ -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>
|
||||
|
@ -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") }
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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">
|
||||
|
@ -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>
|
||||
|
||||
|
||||
);
|
||||
};
|
@ -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>
|
||||
)
|
||||
;
|
||||
};
|
@ -133,6 +133,7 @@
|
||||
"stations": "Stations",
|
||||
"trains": "Trains",
|
||||
"leaderboard": "Leaderboard",
|
||||
"steam_leaderboard": "Steam leaderboard",
|
||||
"info": "INFO",
|
||||
"admin": "ADMIN"
|
||||
},
|
||||
|
@ -133,6 +133,7 @@
|
||||
"stations": "Stacje",
|
||||
"trains": "Pociągi",
|
||||
"leaderboard": "Tablica wyników",
|
||||
"steam_leaderboard": "Tablica wyników steam",
|
||||
"info": "INFO",
|
||||
"admin": "ADMIN"
|
||||
},
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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 }/> }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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 }/> }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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;
|
||||
}>
|
||||
}
|
@ -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
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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());
|
Loading…
x
Reference in New Issue
Block a user