Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

117 changed files with 5245 additions and 1897 deletions

View File

@ -1,31 +0,0 @@
---
kind: pipeline
type: docker
name: build
steps:
- name: build-backend
image: plugins/docker
settings:
insecure: true
repo: 10.5.0.103:1222/simrail-backend
registry: 10.5.0.103:1222
context: './packages/backend'
dockerfile: './packages/backend/Dockerfile'
tags:
- latest
- name: build-frontend
image: plugins/docker
settings:
insecure: true
repo: 10.5.0.103:1222/simrail-frontend
registry: 10.5.0.103:1222
context: './packages/frontend'
dockerfile: './packages/frontend/Dockerfile'
tags:
- latest
trigger:
branch:
- main

View File

@ -1,7 +0,0 @@
<component name="CopyrightManager">
<settings default="gnu agpl alekswilc">
<module2copyright>
<element module="Project Files" copyright="gnu agpl alekswilc" />
</module2copyright>
</settings>
</component>

View File

@ -1,10 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<Languages>
<language minSize="457" name="TypeScript" />
</Languages>
</inspection_tool>
</profile>
</component>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<file url="file://$PROJECT_DIR$/packages/frontend" libraries="{script}" />
</component>
</project>

View File

@ -4,6 +4,7 @@
"main": "dist/index.js",
"scripts": {
"build": "yarn workspace backend build && yarn workspace frontend build",
"postbuild": "copyfiles --error ./LICENSE.txt ./dist && copyfiles --error ./LICENSE.txt ./dist/frontend/",
"start": "concurrently --kill-others-on-fail \"yarn workspace backend start\" \"yarn workspace frontend dev\""
},
"workspaces": [

View File

@ -1,20 +0,0 @@
FROM node:21-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN yarn add -D typescript
RUN yarn rawbuild
RUN ls
# Install Doppler CLI
RUN wget -q -t3 'https://packages.doppler.com/public/cli/rsa.8004D9FF50437357.key' -O /etc/apk/keys/cli@doppler-8004D9FF50437357.rsa.pub && \
echo 'https://packages.doppler.com/public/cli/alpine/any-version/main' | tee -a /etc/apk/repositories && \
apk add doppler
ENTRYPOINT ["doppler", "run", "--"]
CMD ["node", "/app/dist"]

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -4,8 +4,7 @@
"main": "../../dist/backend/index.js",
"version": "3.0.0",
"scripts": {
"build": "docker build --progress=plain -t simrailpro:backend .",
"rawbuild": "yarn tsc",
"build": "tsc",
"start": "yarn build && doppler run node ../../dist/backend/index.js"
},
"author": "Aleksander <alekswilc> Wilczyński",
@ -16,7 +15,6 @@
"@types/node": "^22.10.1",
"@types/uuid": "^10.0.0",
"copyfiles": "^2.4.1",
"tsc": "^2.0.4",
"typescript": "^5.5.4"
},
"type": "module",

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -40,20 +40,12 @@ interface ActiveStation
steam: string;
}
import { Server } from "@simrail/types";
import { generateUrl } from "../../util/imgproxy.js";
const sortFunction = (a: ActiveStation | ActiveTrain, b: ActiveStation | ActiveTrain) =>
{
if (a.server.includes("pl") && !b.server.includes("pl"))
{
const sortFunction = (a: ActiveStation | ActiveTrain, b: ActiveStation | ActiveTrain) => {
if (a.server.includes('pl') && !b.server.includes('pl'))
return -1;
}
if (!a.server.includes("pl") && b.server.includes("pl"))
{
if (!a.server.includes('pl') && b.server.includes('pl'))
return 1;
}
return 0;
};
@ -66,22 +58,15 @@ export class ActivePlayersRoute
app.get("/train", async (req, res) =>
{
const s = req.query.query?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
const sserver = req.query.server?.toString();
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
let a: ActiveTrain[] = [];
for (const data of sserver ? [ client.trains[ sserver as Server["ServerCode"] ] ] : Object.values(client.trains))
for (const data of Object.values(client.trains))
{
for (const d of data.filter(d => d.TrainData.ControlledBySteamID))
{
const p = await PlayerUtil.getPlayer(d.TrainData.ControlledBySteamID!);
if (p && process.env.IMGPROXY_KEY)
{
p.avatar = generateUrl(p.avatar);
}
p && a.push({
server: d.ServerCode,
player: p,
@ -105,33 +90,24 @@ export class ActivePlayersRoute
res.json(
new SuccessResponseBuilder()
.setCode(200)
.setData({
records: a,
servers: Object.keys(client.stations),
})
.setData({ records: a })
.toJSON(),
);
});
app.get("/station", async (req, res) =>
{
const s = req.query.query?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
const sserver = req.query.server?.toString();
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
let a: ActiveStation[] = [];
for (const server of sserver ? [ sserver ] : Object.keys(client.stations))
for (const server of Object.keys(client.stations))
{
for (const d of client.stations[ server ].filter(d => d.DispatchedBy.length && d.DispatchedBy[ 0 ]?.SteamId))
{
// todo: optimize
const p = await PlayerUtil.getPlayer(d.DispatchedBy[ 0 ].SteamId!);
if (p && process.env.IMGPROXY_KEY)
{
p.avatar = generateUrl(p.avatar);
}
p && a.push({
server: server,
player: p,
@ -150,16 +126,14 @@ export class ActivePlayersRoute
}
a = arrayGroupBy(a, d => d.server)
.sort(sortFunction);
res.json(
new SuccessResponseBuilder()
.setCode(200)
.setData({
records: a,
servers: Object.keys(client.stations),
})
.setData({ records: a })
.toJSON(),
);
});

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -17,9 +17,8 @@
import { Router } from "express";
import { PipelineStage } from "mongoose";
import { IProfile, MProfile } from "../../mongo/profile.js";
import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js";
import { SuccessResponseBuilder } from "../responseBuilder.js";
import { escapeRegexString, removeProperties } from "../../util/functions.js";
import { generateUrl } from "../../util/imgproxy.js";
const generateSearch = (regex: RegExp) => [
{
@ -44,17 +43,7 @@ export class LeaderboardRoute
app.get("/train", async (req, res) =>
{
const s = req.query.query?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
const limit = parseInt(req.query.limit as string) || 12;
const page = parseInt(req.query.page as string) || 1;
if (page < 0 || limit < 0)
{
res.status(400).json(new ErrorResponseBuilder()
.setCode(400).setData("Invalid page and/or limit"));
return;
}
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
const filter: PipelineStage[] = [
{
@ -72,61 +61,23 @@ export class LeaderboardRoute
},
});
const sortBy = sortyByMap[ req.query.sort_by?.toString() ?? "distance" ] ?? sortyByMap.distance;
const sortBy = sortyByMap[ req.query.s?.toString() ?? "distance" ] ?? sortyByMap.distance;
filter.push({
$sort: sortBy,
});
filter.push({
$facet: {
data: [
{ $match: {} },
{ $skip: (page - 1) * limit },
{ $limit: limit },
],
count: [
{ $count: "count" },
],
},
});
const records = await MProfile.aggregate(filter);
process.env.IMGPROXY_KEY && records[ 0 ].data && records[ 0 ].data.forEach((p: IProfile) =>
{
if (p.avatar.includes("imgproxy.alekswilc.dev"))
{
return;
}
p.avatar = generateUrl(p.avatar);
});
const records = await MProfile.aggregate(filter)
.sort(sortBy)
.limit(50);
res.json(
new SuccessResponseBuilder()
new SuccessResponseBuilder<{ records: Omit<IProfile, "_id" | "__v">[] }>()
.setCode(200)
.setData({
records: records[ 0 ].data.map((x: IProfile) => removeProperties<Omit<IProfile, "_id" | "__v">>(x, [ "_id", "__v" ])),
pages: Math.floor((records[ 0 ]?.count?.[ 0 ]?.count ?? 0) / limit),
})
.setData({ records: records.map(x => removeProperties<Omit<IProfile, "_id" | "__v">>(x, [ "_id", "__v" ])) })
.toJSON(),
);
});
app.get("/station", async (req, res) =>
{
const s = req.query.query?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
const limit = parseInt(req.query.limit as string) || 12;
const page = parseInt(req.query.page as string) || 1;
if (page < 0 || limit < 0)
{
res.status(400).json(new ErrorResponseBuilder()
.setCode(400).setData("Invalid page and/or limit"));
return;
}
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
const filter: PipelineStage[] = [
{
@ -143,43 +94,14 @@ export class LeaderboardRoute
},
});
filter.push({
$sort: {
dispatcherTime: -1,
},
});
filter.push({
$facet: {
data: [
{ $match: {} },
{ $skip: (page - 1) * limit },
{ $limit: limit },
],
count: [
{ $count: "count" },
],
},
});
const records = await MProfile.aggregate(filter);
process.env.IMGPROXY_KEY && records[ 0 ].data && records[ 0 ].data.forEach((p: IProfile) =>
{
if (p.avatar.includes("imgproxy.alekswilc.dev"))
{
return;
}
p.avatar = generateUrl(p.avatar);
});
const records = await MProfile.aggregate(filter)
.sort({ dispatcherTime: -1 })
.limit(50);
res.json(
new SuccessResponseBuilder()
new SuccessResponseBuilder<{ records: Omit<IProfile, "_id" | "__v">[] }>()
.setCode(200)
.setData({
records: records[ 0 ].data.map((x: IProfile) => removeProperties<Omit<IProfile, "_id" | "__v">>(x, [ "_id", "__v" ])),
pages: Math.floor((records[ 0 ]?.count?.[ 0 ]?.count ?? 0) / limit),
})
.setData({ records: records.map(x => removeProperties<Omit<IProfile, "_id" | "__v">>(x, [ "_id", "__v" ])) })
.toJSON(),
);
});

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -19,7 +19,6 @@ import { MTrainLog } from "../../mongo/trainLog.js";
import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js";
import { MStationLog } from "../../mongo/stationLog.js";
import { IProfile } from "../../mongo/profile.js";
import { generateUrl } from "../../util/imgproxy.js";
export class LogRoute
@ -61,13 +60,7 @@ export class LogRoute
return;
}
if (process.env.IMGPROXY_KEY)
{
log.player.avatar = generateUrl(log.player.avatar, "rs:auto:256:256:1/f:png");
}
res.status(200).json(new SuccessResponseBuilder().setCode(200).setData(log.toJSON()).toJSON());
res.status(200).json(new SuccessResponseBuilder().setCode(200).setData(log.toJSON()));
});
return app;

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -19,8 +19,7 @@ import { PipelineStage } from "mongoose";
import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js";
import { PlayerUtil } from "../../util/PlayerUtil.js";
import { IProfile, MProfile } from "../../mongo/profile.js";
import { escapeRegexString } from "../../util/functions.js";
import { generateUrl } from "../../util/imgproxy.js";
import { escapeRegexString, removeProperties } from "../../util/functions.js";
const generateSearch = (regex: RegExp) => [
{
@ -112,11 +111,6 @@ export class ProfilesRoute
}
if (process.env.IMGPROXY_KEY)
{
player.avatar = generateUrl(player.avatar, "rs:auto:256:256:1/f:png");
}
res.json(
new SuccessResponseBuilder()
.setCode(200)
@ -133,16 +127,6 @@ export class ProfilesRoute
{
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
const limit = parseInt(req.query.limit as string) || 12;
const page = parseInt(req.query.page as string) || 1;
if (page < 0 || limit < 0)
{
res.status(400).json(new ErrorResponseBuilder()
.setCode(400).setData("Invalid page and/or limit"));
return;
}
const filter: PipelineStage[] = [
{
$match: {
@ -159,38 +143,13 @@ export class ProfilesRoute
},
});
filter.push({
$facet: {
data: [
{ $match: {} },
{ $skip: (page - 1) * limit },
{ $limit: limit },
],
count: [
{ $count: "count" },
],
},
});
const records = await MProfile.aggregate(filter);
process.env.IMGPROXY_KEY && records[ 0 ].data && records[ 0 ].data.forEach((p: IProfile) =>
{
if (p.avatar.includes("imgproxy.alekswilc.dev"))
{
return;
}
p.avatar = generateUrl(p.avatar);
});
const records = await MProfile.aggregate(filter)
.limit(50);
res.json(
new SuccessResponseBuilder()
new SuccessResponseBuilder<{ records: Omit<IProfile, "_id" | "__v">[] }>()
.setCode(200)
.setData({
records: records[ 0 ].data,
pages: Math.floor((records[ 0 ]?.count?.[ 0 ]?.count ?? 0) / limit),
})
.setData({ records: records.map(x => removeProperties<Omit<IProfile, "_id" | "__v">>(x, [ "_id", "__v" ])) })
.toJSON(),
);
});

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -18,9 +18,8 @@ import { Router } from "express";
import { IStationLog, MStationLog } from "../../mongo/stationLog.js";
import { PipelineStage } from "mongoose";
import { escapeRegexString } from "../../util/functions.js";
import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js";
import { IProfile, MProfile } from "../../mongo/profile.js";
import { generateUrl } from "../../util/imgproxy.js";
import { SuccessResponseBuilder } from "../responseBuilder.js";
import { MProfile } from "../../mongo/profile.js";
const generateSearch = (regex: RegExp) => [
{
@ -48,19 +47,7 @@ export class StationsRoute
app.get("/", async (req, res) =>
{
const s = req.query.query?.toString().split(",").map(x => new RegExp(escapeRegexString(x.trim()), "i"));
const server = req.query.server?.toString();
const limit = parseInt(req.query.limit as string) || 12;
const page = parseInt(req.query.page as string) || 1;
if (page < 0 || limit < 0)
{
res.status(400).json(new ErrorResponseBuilder()
.setCode(400).setData("Invalid page and/or limit"));
return;
}
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
const filter: PipelineStage[] = [];
s && filter.push({
@ -71,56 +58,16 @@ export class StationsRoute
},
});
server && filter.push({
$match: {
server: {
$regex: new RegExp(escapeRegexString(server), "i"),
},
},
});
const records = await MStationLog.aggregate(filter)
.sort({ leftDate: -1 })
.limit(30);
filter.push({
$sort: {
leftDate: -1,
},
});
filter.push({
$facet: {
data: [
{ $match: {} },
{ $skip: (page - 1) * limit },
{ $limit: limit },
],
count: [
{ $count: "count" },
],
},
});
const records = await MStationLog.aggregate(filter);
await MProfile.populate(records, { path: "data.player" });
process.env.IMGPROXY_KEY && records[ 0 ].data && records[ 0 ].data.forEach((p: IStationLog & {
player: IProfile
}) =>
{
if (p.player.avatar.includes("imgproxy.alekswilc.dev"))
{
return;
}
p.player.avatar = generateUrl(p.player.avatar);
});
await MProfile.populate(records, { path: "player" });
res.json(
new SuccessResponseBuilder()
new SuccessResponseBuilder<{ records: IStationLog[] }>()
.setCode(200)
.setData({
records: records[ 0 ].data,
pages: Math.floor((records[ 0 ]?.count?.[ 0 ]?.count ?? 0) / limit),
servers: Object.keys(client.stations),
})
.setData({ records })
.toJSON(),
);
});

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -46,6 +46,7 @@ export class StatsRoute
);
});
return app;
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -17,10 +17,9 @@
import { Router } from "express";
import { PipelineStage } from "mongoose";
import { ITrainLog, MTrainLog } from "../../mongo/trainLog.js";
import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js";
import { SuccessResponseBuilder } from "../responseBuilder.js";
import { escapeRegexString } from "../../util/functions.js";
import { IProfile, MProfile } from "../../mongo/profile.js";
import { generateUrl } from "../../util/imgproxy.js";
import { MProfile } from "../../mongo/profile.js";
const generateSearch = (regex: RegExp) => [
{
@ -45,20 +44,10 @@ export class TrainsRoute
app.get("/", async (req, res) =>
{
const s = req.query.query?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
const server = req.query.server?.toString();
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
const filter: PipelineStage[] = [];
const limit = parseInt(req.query.limit as string) || 12;
const page = parseInt(req.query.page as string) || 1;
if (page < 0 || limit < 0)
{
res.status(400).json(new ErrorResponseBuilder()
.setCode(400).setData("Invalid page and/or limit"));
return;
}
s && filter.push({
$match: {
@ -68,55 +57,17 @@ export class TrainsRoute
},
});
server && filter.push({
$match: {
server: {
$regex: new RegExp(escapeRegexString(server), "i"),
},
},
});
const records = await MTrainLog.aggregate(filter)
.sort({ leftDate: -1 })
.limit(30);
filter.push({
$sort: {
leftDate: -1,
},
});
filter.push({
$facet: {
data: [
{ $match: {} },
{ $skip: (page - 1) * limit },
{ $limit: limit },
],
count: [
{ $count: "count" },
],
},
});
const records = await MTrainLog.aggregate(filter);
await MProfile.populate(records, { path: "data.player" });
process.env.IMGPROXY_KEY && records[ 0 ].data && records[ 0 ].data.forEach((p: ITrainLog & {
player: IProfile
}) =>
{
if (p.player.avatar.includes("imgproxy.alekswilc.dev"))
{
return;
}
p.player.avatar = generateUrl(p.player.avatar);
});
await MProfile.populate(records, { path: "player" });
res.json(
new SuccessResponseBuilder()
new SuccessResponseBuilder<{ records: ITrainLog[] }>()
.setCode(200)
.setData({
records: records[ 0 ].data,
pages: Math.floor((records[ 0 ]?.count?.[ 0 ]?.count ?? 0) / limit),
servers: Object.keys(client.stations),
records,
})
.toJSON(),
);

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -24,7 +24,6 @@ import { TrainsModule } from "./modules/trains.js";
import { Server, Station, Train } from "@simrail/types";
import dayjs from "dayjs";
import { TMProfile } from "./mongo/profile.js";
import { GitUtil } from "./util/git.js";
;(async () =>
{
@ -48,7 +47,6 @@ import { GitUtil } from "./util/git.js";
}
ApiModule.load(); // TODO: use fastify
GitUtil.getData();
if (process.env.NODE_ENV === "development")
{

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -18,7 +18,7 @@ import { IPlayerPayload, IPlayerStatsPayload } from "../types/player.js";
import { MProfile } from "../mongo/profile.js";
import { assert } from "node:console";
const steamKeys: string[] = JSON.parse(process.env.STEAM_APIKEY!);
const STEAM_API_KEY = process.env.STEAM_APIKEY;
const steamFetch = (url: string) =>
{
@ -28,13 +28,12 @@ const steamFetch = (url: string) =>
{
const req = () =>
{
const steamKey = steamKeys[ Math.floor(Math.random() * steamKeys.length) ];
fetch(url.replace("[STEAMKEY]", steamKey), { signal: AbortSignal.timeout(10000) }).then(x => x.json())
fetch(url, { signal: AbortSignal.timeout(10000) }).then(x => x.json())
.then(x => res(x))
.catch(() =>
{
console.log("STEAM request failed! ", url.replace("[STEAMKEY]", steamKey), retries);
console.log("STEAM request failed! ", url.replace(STEAM_API_KEY!, "[XXX]"), retries);
retries++;
setTimeout(() => req(), retries * 1000);
@ -55,7 +54,7 @@ export class PlayerUtil
if (!player)
{
const data = await steamFetch(`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=[STEAMKEY]&format=json&steamids=${ steamId }`) as IPlayerPayload;
const data = await steamFetch(`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=${ STEAM_API_KEY }&format=json&steamids=${ steamId }`) as IPlayerPayload;
assert(data.response.players, "Expected data.response.players to be truthy");
@ -150,7 +149,7 @@ export class PlayerUtil
public static async getPlayerSteamData(steamId: string)
{
const data = await steamFetch(`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=[STEAMKEY]&format=json&steamids=${ steamId }`) as IPlayerPayload;
const data = await steamFetch(`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=${ STEAM_API_KEY }&format=json&steamids=${ steamId }`) as IPlayerPayload;
if (!data?.response?.players?.length)
{
@ -162,7 +161,7 @@ export class PlayerUtil
public static async getPlayerStats(steamId: string)
{
const data = await steamFetch(`https://api.steampowered.com/ISteamUserStats/GetUserStatsForGame/v0002/?appid=1422130&key=[STEAMKEY]&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)
{

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,20 +1,3 @@
/*
* Copyright (C) 2025 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 wcmatch from "wildcard-match";
/*

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -18,38 +18,36 @@ import { execSync } from "child_process";
export class GitUtil
{
private static cache: { version?: string, commit?: string } = undefined!;
private static cache: { lastUpdated: number, version?: string, commit?: string } = undefined!;
private static getLatestVersion()
public static getLatestVersion()
{
return process.env.CURRENT_VERSION;
// try
// {
// const data = execSync("git describe --tags --exact-match").toString();
// return data.replace("\n", "");
// } catch
// {
// return undefined;
// }
try
{
const data = execSync("git describe --tags --exact-match").toString();
return data.replace("\n", "");
} catch
{
return undefined;
}
}
private static getLatestCommit()
public static getLatestCommit()
{
return process.env.CURRENT_COMMIT;
// try
// {
// const data = execSync("git rev-parse --short HEAD").toString();
// return data.replace("\n", "");
// } catch
// {
// return undefined;
// }
try
{
const data = execSync("git rev-parse --short HEAD").toString();
return data.replace("\n", "");
} catch
{
return undefined;
}
}
public static getData()
{
if (this.cache)
if (this.cache && (this.cache.lastUpdated - Date.now()) < 30_000)
{
return this.cache;
}
@ -57,6 +55,7 @@ export class GitUtil
const data = {
version: this.getLatestVersion(),
commit: this.getLatestCommit(),
lastUpdated: Date.now(),
};
this.cache = data;

View File

@ -1,42 +0,0 @@
/*
* Copyright (C) 2025 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 { createHmac, randomBytes } from "node:crypto";
import { GitUtil } from "./git.js";
export const imgProxySign = (target: string) =>
{
const hmac = createHmac("sha256", Buffer.from(process.env.IMGPROXY_KEY!, 'hex'));
hmac.update(Buffer.from(process.env.IMGPROXY_SALT!, 'hex'));
hmac.update(target);
return hmac.digest("base64url");
};
export const generateUrl = (url: string, options: string = "rs:auto:128:128:1/f:png") =>
{
if (url.includes('https://proxy.cdn.alekswilc.dev/')) return url;
if (process.env.NODE_ENV === "development")
{
options += "/cb:" + randomBytes(4).toString('hex');
} else if (GitUtil.getData().version) {
options += "/cb:" + GitUtil.getData().version;
}
const signature = imgProxySign(`/${ options }/plain/${ url }`);
return `https://proxy.cdn.alekswilc.dev/${ signature }/${ options }/plain/${ url }`;
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -55,7 +55,7 @@
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
"outDir": "../../dist/backend", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +0,0 @@
FROM node:18-alpine
WORKDIR /app
COPY package.json .
RUN npm install
RUN npm i -g serve vite
COPY . .
RUN npm run rawbuild
EXPOSE 3000
CMD [ "serve", "-s", "dist" ]

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="script" level="application" />
</component>
</module>

View File

@ -1,5 +1,5 @@
<!--
~ Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
~ 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
@ -19,7 +19,6 @@
<head>
<meta charset="UTF-8"/>
<link rel="icon" type="image/jpg" href="/favicon.png"/>
<link rel="preconnect" href="https://umami.alekswilc.dev/script.js">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>simrail.pro | Simrail Logs</title>

View File

@ -5,9 +5,8 @@
"type": "module",
"scripts": {
"dev": "vite",
"rawbuild": "vite build",
"preview": "vite preview",
"build": "docker build --progress=plain -t simrailpro:frontend ."
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"dayjs": "^1.11.13",
@ -36,7 +35,7 @@
"prettier": "^3.0.0",
"prettier-plugin-tailwindcss": "^0.4.1",
"tailwindcss": "^3.4.1",
"vite": "^6.2.0",
"vite": "^4.4.7",
"webpack": "^5.88.2"
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,4 +0,0 @@
User-agent: *
Disallow: /api/
Disallow: /cgi-bin/
Sitemap: https://simrail.pro/sitemap.xml

View File

@ -1,42 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url>
<loc>https://simrail.pro/</loc>
<lastmod>2024-12-24T23:09:07+00:00</lastmod>
<priority>1.00</priority>
</url>
<url>
<loc>https://simrail.pro/profiles</loc>
<lastmod>2024-12-24T23:09:07+00:00</lastmod>
<priority>0.90</priority>
</url>
<url>
<loc>https://simrail.pro/leaderboard</loc>
<lastmod>2024-12-24T23:09:07+00:00</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://simrail.pro/logs/stations</loc>
<lastmod>2024-12-24T23:09:07+00:00</lastmod>
<priority>0.70</priority>
</url>
<url>
<loc>https://simrail.pro/logs/trains</loc>
<lastmod>2024-12-24T23:09:07+00:00</lastmod>
<priority>0.70</priority>
</url>
<url>
<loc>https://simrail.pro/active/stations</loc>
<lastmod>2024-12-24T23:09:07+00:00</lastmod>
<priority>0.60</priority>
</url>
<url>
<loc>https://simrail.pro/active/trains</loc>
<lastmod>2024-12-24T23:09:07+00:00</lastmod>
<priority>0.60</priority>
</url>
</urlset>

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -21,7 +21,8 @@ import { Loader } from "./components/mini/loaders/PageLoader.tsx";
import { Home } from "./pages/Home";
import DefaultLayout from "./layout/DefaultLayout";
import "./i18n";
import { Leaderboard } from "./pages/leaderboard/Leaderboard.tsx";
import { TrainLeaderboard } from "./pages/leaderboard/TrainLeaderboard.tsx";
import { StationLeaderboard } from "./pages/leaderboard/StationsLeaderboard.tsx";
import { TrainLogs } from "./pages/logs/TrainLogs.tsx";
import { StationLogs } from "./pages/logs/StationLogs.tsx";
import { Profile } from "./pages/profiles/Profile.tsx";
@ -82,12 +83,12 @@ function App()
}
/>
<Route
path="/leaderboard/"
path="/leaderboard/trains"
element={
<>
<PageMeta title="simrail.pro | Train Leaderboard"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
<Leaderboard/>
<TrainLeaderboard/>
</>
}
/>
@ -114,6 +115,18 @@ function App()
}
/>
<Route
path="/leaderboard/stations"
element={
<>
<PageMeta title="simrail.pro | Station Leaderboard"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
<StationLeaderboard/>
</>
}
/>
<Route
path="/active/trains"
element={

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -0,0 +1,15 @@
export const SuccessAlert = ({ title, description }: { title: string, description: string }) =>
<div
className="flex w-full border-l-6 border-[#34D399] bg-[#34D399] bg-opacity-[15%] dark:bg-[#1B1B24] px-7 py-8 shadow-md dark:bg-opacity-30 md:p-9">
<div className="mr-5 flex h-9 w-full max-w-[36px] items-center justify-center rounded-lg bg-[#34D399]">
<SuccessAlertIcon/>
</div>
<div className="w-full">
<h5 className="mb-3 text-lg font-semibold text-black dark:text-[#34D399] ">
{ title }
</h5>
<p className="text-base leading-relaxed text-body">
{ description }
</p>
</div>
</div>;

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -76,13 +76,10 @@ export const Header = (props: {
<div className="flex items-center gap-3 2xsm:gap-7">
<ul className="flex items-center gap-2 2xsm:gap-4">
<a className="cursor-pointer" onClick={ () => i18n.changeLanguage("pl") }>
<ReactCountryFlag countryCode={ "PL" } svg alt={ "PL" }/>
<ReactCountryFlag countryCode={ "PL" } svg/>
</a>
<a className="cursor-pointer" onClick={ () => i18n.changeLanguage("en") }>
<ReactCountryFlag countryCode={ "US" } svg alt={ "EN" }/>
</a>
<a className="cursor-pointer" onClick={ () => i18n.changeLanguage("cs") }>
<ReactCountryFlag countryCode={ "CZ" } svg alt={ "CZ" }/>
<ReactCountryFlag countryCode={ "US" } svg/>
</a>
</ul>
<ul className="flex items-center gap-2 2xsm:gap-4">

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -19,6 +19,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ErrorAlertIcon } from "../icons/AlertIcons.tsx";
export const LoadError = () =>
{
const { t } = useTranslation();

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,20 +1,3 @@
/*
* Copyright (C) 2025 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 { Dispatch, SetStateAction } from "react";
export const ConfirmModal = ({ showModal, setShowModal, onConfirm, title, description }: {

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -153,17 +153,70 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
</NavLink>
</li>
<li>
<SidebarLinkGroup
isOpen={ true }
activeCondition={
pathname === "/leaderboard" || pathname.includes("leaderboard")
}
>
{ (handleClick, open) =>
{
return (
<React.Fragment>
<NavLink
to="/leaderboard"
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.includes("/leaderboard") &&
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/steam"))) &&
"bg-graydark dark:bg-meta-4"
}` }
onClick={ (e) =>
{
e.preventDefault();
sidebarExpanded
? handleClick()
: setSidebarExpanded(true);
} }
>
<FaChartSimple/>
{ t("sidebar.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/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/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>
<SidebarLinkGroup
isOpen={ true }

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,78 +0,0 @@
/*
* Copyright (C) 2025 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 { Dispatch, SetStateAction } from "react";
export const Paginator = ({ page, setPage, pages }: {
page: number,
pages: number,
setPage: Dispatch<SetStateAction<number>>
}) =>
{
let numbers = [ 1, page - 2, page - 1, page, page + 1, page + 2 ];
page === 1 && (numbers = [ page, page + 1, page + 2, page + 3, page + 4 ]);
page === 2 && (numbers = [ page - 1, page, page + 1, page + 2, page + 3 ]);
page === 3 && (numbers = [ page - 2, page - 1, page, page + 1, page + 2 ]);
(page === pages) && (numbers = [ 1, page - 4, page - 3, page - 2, page - 1 ]);
(page === (pages - 1)) && (numbers = [ 1, page - 3, page - 2, page - 1, page ]);
(page === (pages - 2)) && (numbers = [ 1, page - 2, page - 1, page, page + 1 ]);
numbers = numbers.filter(x => (pages + 1) >= x && x > 0);
return <div
className="rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark flex flex-row align-center justify-center p-2">
<ul className="flex flex-wrap items-center">
<li>
<a className="cursor-pointer flex h-9 w-9 items-center justify-center rounded-l-md border border-stroke hover:border-primary hover:bg-gray hover:text-primary dark:border-strokedark dark:hover:border-primary dark:hover:bg-graydark"
onClick={ () => setPage(page => (page - 1) < 1 ? 1 : page - 1) }>
<svg className="fill-current" width="8" height="16" viewBox="0 0 8 16" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M7.17578 15.1156C7.00703 15.1156 6.83828 15.0593 6.72578 14.9187L0.369531 8.44995C0.116406 8.19683 0.116406 7.80308 0.369531 7.54995L6.72578 1.0812C6.97891 0.828076 7.37266 0.828076 7.62578 1.0812C7.87891 1.33433 7.87891 1.72808 7.62578 1.9812L1.71953 7.99995L7.65391 14.0187C7.90703 14.2718 7.90703 14.6656 7.65391 14.9187C7.48516 15.0312 7.34453 15.1156 7.17578 15.1156Z"
fill=""></path>
</svg>
</a></li>
{ numbers.map(num =>
{
return <li>
<a onClick={ () => setPage(num) }
className={ `cursor-pointer flex items-center justify-center border border-stroke border-l-transparent py-[5px] px-4 font-medium hover:border-primary hover:bg-gray hover:text-primary dark:border-strokedark dark:hover:border-primary dark:hover:bg-graydark select-none ${ page === num && "text-primary border-primary dark:border-primary" }` }>{ num }</a>
</li>;
}) }
{ !!pages && <li>
<a className={ `cursor-pointer flex items-center justify-center border border-stroke border-l-transparent py-[5px] px-4 font-medium hover:border-primary hover:bg-gray hover:text-primary dark:border-strokedark dark:hover:border-primary dark:hover:bg-graydark select-none ${ page === pages && "text-primary border-primary dark:border-primary" }` }
onClick={ () => setPage(pages) }>{ pages }</a></li> }
<li>
<a className="cursor-pointer flex h-9 w-9 items-center justify-center rounded-r-md border border-stroke border-l-transparent hover:border-primary hover:bg-gray hover:text-primary dark:border-strokedark dark:hover:border-primary dark:hover:bg-graydark select-none"
onClick={ () => setPage(page => (page + 1) > pages ? pages : page + 1) }>
<svg className="fill-current" width="8" height="16" viewBox="0 0 8 16" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M0.819531 15.1156C0.650781 15.1156 0.510156 15.0593 0.369531 14.9468C0.116406 14.6937 0.116406 14.3 0.369531 14.0468L6.27578 7.99995L0.369531 1.9812C0.116406 1.72808 0.116406 1.33433 0.369531 1.0812C0.622656 0.828076 1.01641 0.828076 1.26953 1.0812L7.62578 7.54995C7.87891 7.80308 7.87891 8.19683 7.62578 8.44995L1.26953 14.9187C1.15703 15.0312 0.988281 15.1156 0.819531 15.1156Z"
fill="">
</path>
</svg>
</a>
</li>
</ul>
</div>;
};

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -14,60 +14,9 @@
* See LICENSE for more.
*/
import { ChangeEventHandler, Dispatch, ReactNode, SetStateAction } from "react";
import { ChangeEventHandler } from "react";
import { useTranslation } from "react-i18next";
export const SearchWithServerSelector = ({ searchItem, handleInputChange, children, servers, server, setServer }: {
searchItem: string;
handleInputChange: ChangeEventHandler,
children?: ReactNode
servers: string[];
server: string;
setServer: Dispatch<SetStateAction<string>>
}) =>
{
const { t } = useTranslation();
return <>
<div
className="col-span-12 rounded-sm border border-stroke bg-white px-5 p-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:col-span-8">
<div className="flex flex-col gap-6 xl:flex-row items-center justify-center">
<div className="w-full xl:w-1/2 grow">
<input onChange={ handleInputChange }
value={ searchItem }
type="text" placeholder={ t("search.placeholder_with_station") }
className="w-full rounded border-[1.5px] border-stroke bg-transparent px-5 py-3 font-normal text-black outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:text-white dark:focus:border-primary"/>
</div>
{ servers?.length &&
<div className="items-center justify-center flex gap-2 flex-wrap">
{
servers.map(x =>
{
return <a
onClick={ () =>
{
setServer(x);
} }
className={ `cursor-pointer 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 ${ server === x ? "bg-opacity-50" : "" }` }>{ x.toUpperCase() }</a>;
})
}
<a
onClick={ () =>
{
setServer(undefined!);
} }
className={ `cursor-pointer 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 ${ !server ? "bg-opacity-50" : "" }` }>{ t("search.none") }</a>
</div> }
</div>
{ children }
</div>
</>;
};
import { FcInfo } from "react-icons/fc";
export const Search = ({ searchItem, handleInputChange }: {
searchItem: string;
@ -75,48 +24,25 @@ export const Search = ({ searchItem, handleInputChange }: {
}) =>
{
const { t } = useTranslation();
return <>
<div
className="col-span-12 rounded-sm border border-stroke bg-white px-5 p-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:col-span-8">
<div className="flex flex-col gap-6 xl:flex-row items-center justify-center">
<div className="w-full xl:w-1/2 grow">
<input onChange={ handleInputChange }
value={ searchItem }
type="text" placeholder={ t("search.placeholder") }
className="w-full rounded border-[1.5px] border-stroke bg-transparent px-5 py-3 font-normal text-black outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:text-white dark:focus:border-primary"/>
</div>
</div>
</div>
</>;
};
export const SearchWithChild = ({ searchItem, handleInputChange, children }: {
searchItem: string;
handleInputChange: ChangeEventHandler,
children: ReactNode
}) =>
{
const { t } = useTranslation();
return <>
<div
className="col-span-12 rounded-sm border border-stroke bg-white px-5 pt-7.5 pb-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:col-span-8">
{ children }
<div className="mt-5 flex flex-col gap-6 xl:flex-row items-center justify-center">
<div className="w-full xl:w-1/2 grow">
<input onChange={ handleInputChange }
<div className="flex justify-center items-center">
<input
className="w-full rounded border border-stroke bg-gray py-3 pl-5 pr-5 text-black focus:border-primary focus-visible:outline-none dark:border-strokedark dark:bg-meta-4 dark:text-white dark:focus:border-primary"
type="text"
onChange={ handleInputChange }
value={ searchItem }
type="text" placeholder={ t("search.placeholder") }
className="w-full rounded border-[1.5px] border-stroke bg-transparent px-5 py-3 font-normal text-black outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:text-white dark:focus:border-primary"/>
placeholder={ t("search.placeholder") }
/>
</div>
<div className="flex pt-2 gap-1 align-center">
<FcInfo/>
<p className="text-sm text-black dark:text-white">
{ t("search.tip") }
</p>
</div>
</div>
</>;
};

View File

@ -0,0 +1,24 @@
/*
* 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 { FaUserShield, FaUserSlash, FaUserLock } from "react-icons/fa6";
export const UserIcons = ({ flags }: { flags: string[] }) =>
{
return <> { flags.includes("administrator") &&
<FaUserShield className={ "inline text-meta-1 ml-1" }/> } { flags.includes("leaderboard_hidden") &&
<FaUserLock className={ "inline text-meta-6 ml-1" }/> } { flags.includes("hidden") &&
<FaUserSlash className={ "inline text-meta-1 ml-1" }/> }</>;
};

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -18,51 +18,76 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TActiveStationPlayersData } from "../../../types/active.ts";
import { UserIcons } from "../../mini/icons/UserIcons.tsx";
import { toast } from "react-toastify";
export const ActiveStationTable = ({ stations }: { stations: TActiveStationPlayersData[] }) =>
{
const { t } = useTranslation();
const report = (data: TActiveStationPlayersData) =>
{
toast.info(t("log.toasts.report"), {
autoClose: 5000,
});
void navigator.clipboard.writeText(`;user: \`${ data.player.username }\`\n;steam: \`https://steamcommunity.com/profiles/${ data.player.id }\`\n;server: \`${ data.server.toUpperCase() }\`\n;station: \`${ data.stationName }\`\n;link: https://${ location.hostname }/profile/${data.player.id}\n\n`);
};
return (
<div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4">
{ stations.map((station) =>
{
return <div
key={ station.server + station.stationShort }
className="flex flex-col align-center items-center rounded-sm border border-stroke bg-stroke shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="p-4">
<img className="rounded-full" src={ station.player.avatar } alt="Player"/>
<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("active.server") }
</h5>
</div>
<div className="flex flex-col p-2 align-center items-center">
<h4 className="mb-3 text-xl font-semibold text-black dark:text-white">
{ station.player.username }
<UserIcons flags={ station.player.flags }/>
</h4>
<p>{ t("log.station.station", { name: station.stationName, short: station.stationShort }) }</p>
<p>{ t("log.station.server", { server: station.server.toUpperCase() }) }</p>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("active.user") }
</h5>
</div>
<div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap mt-auto mb-2">
<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("log.buttons.profile") }</Link>
<a
onClick={ () => report(station) }
className="cursor-pointer inline-flex items-center justify-center rounded-md bg-danger py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5"
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("active.station") }
</h5>
</div>
<div className="hidden p-2.5 text-center sm:block xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("active.actions") }
</h5>
</div>
</div>
{ stations.map((station, key) => (
<div
className={ `grid grid-cols-3 sm:grid-cols-4 ${ stations.length === (key + 1) // todo: ...
? ""
: "border-b border-stroke dark:border-strokedark"
}` }
key={ station.player.id }
>
{ t("log.buttons.report") }
</a>
<div className="flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-6">{ station.server.toUpperCase() }</p>
</div>
</div>;
}) }
<div className="flex justify-center items-center gap-3 p-5 lg:p-5">
<p className="text-black dark:text-white sm:block break-all">
<Link to={ "/profile/" + station.steam }
className="color-orchid">{ station.username }</Link> <UserIcons
flags={ station.player.flags }/>
</p>
</div>
<div className="flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-5">{ station.stationName }</p>
</div>
<div className="hidden items-center justify-center p-2.5 sm:flex xl:p-5">
<Link
to={ "/profile/" + station.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 ${ station.player.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ station.player.flags.includes("private") ? { pointerEvents: "none" } : undefined }
>
{ t("active.profile") }
</Link>
</div>
</div>
)) }
</div>
</div>
);
};

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -18,7 +18,6 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TActiveTrainPlayersData } from "../../../types/active.ts";
import { UserIcons } from "../../mini/icons/UserIcons.tsx";
import { toast } from "react-toastify";
export const ActiveTrainTable = ({ trains }: {
trains: TActiveTrainPlayersData[],
@ -26,48 +25,74 @@ export const ActiveTrainTable = ({ trains }: {
{
const { t } = useTranslation();
const report = (data: TActiveTrainPlayersData) =>
{
toast.info(t("log.toasts.report"), {
autoClose: 5000,
});
void navigator.clipboard.writeText(`;user: \`${ data.player.username }\`\n;steam: \`https://steamcommunity.com/profiles/${ data.player.id }\`\n;server: \`${ data.server.toUpperCase() }\`\n;train: \`${ data.trainNumber }\`\n;link: https://${ location.hostname }/profile/${ data.player.id }\n\n`);
};
return (
<div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4">
{ trains.map((train) =>
{
return <div
key={ train.server + train.trainNumber }
className="flex flex-col align-center items-center rounded-sm border border-stroke bg-stroke shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="p-4">
<img className="rounded-full"
src={ train.player.avatar }
alt="Player"/>
</div>
<div className="flex flex-col p-2 align-center items-center">
<h4 className="mb-3 text-xl font-semibold text-black dark:text-white">
{ train.player.username }
<UserIcons flags={ train.player.flags }/>
</h4>
<p>{ t("log.train.train", { name: train.trainName, number: train.trainNumber }) }</p>
<p>{ t("log.train.server", { server: train.server.toUpperCase() }) }</p>
<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("active.server") }
</h5>
</div>
<div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap mt-auto mb-2">
<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("log.buttons.profile") }</Link>
<a
onClick={ () => report(train) }
className="cursor-pointer inline-flex items-center justify-center rounded-md bg-danger py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5"
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("active.user") }
</h5>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("active.train") }
</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("active.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.steam }
>
{ t("log.buttons.report") }
</a>
<div className="flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-6">{ train.server.toUpperCase() }</p>
</div>
</div>;
}) }
<div className="flex items-center justify-center gap-3 p-5 lg:p-5">
<p className="text-black dark:text-white sm:block break-all">
<Link to={ "/profile/" + train.steam }
className="color-orchid">{ train.username }</Link> <UserIcons
flags={ train.player.flags }/>
</p>
</div>
);
<div className="flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-5">{ train.trainName } { train.trainNumber }</p>
</div>
<div className="hidden items-center justify-center p-2.5 sm:flex xl:p-5">
<Link
to={ "/profile/" + train.steam }
className={ `inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5 ${ train.player.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ train.player.flags.includes("private") ? { pointerEvents: "none" } : undefined }
>
{ t("active.profile") }
</Link>
</div>
</div>
)) }
</div>
</div>
)
;
};

View File

@ -0,0 +1,87 @@
/*
* 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 { UserIcons } from "../../mini/icons/UserIcons.tsx";
export const LeaderboardStationTable = ({ 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> <UserIcons
flags={ station.flags }/>
</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 ${ station.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ station.flags.includes("private") ? { pointerEvents: "none" } : undefined }
>
{ t("leaderboard.profile") }
</Link>
</div>
</div>
)) }
</div>
</div>
);
};

View File

@ -1,75 +0,0 @@
/*
* Copyright (C) 2025 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 { UserIcons } from "../../mini/icons/UserIcons.tsx";
import { FaCrown } from "react-icons/fa6";
export const LeaderboardTable = ({ list, queryType, isUnmodified }: {
list: TLeaderboardRecord[];
queryType: string;
isUnmodified?: boolean
}) =>
{
const { t } = useTranslation();
return (
<div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4">
{ list.map((player, index) =>
{
return <div
key={ player.id }
className="flex flex-col align-center items-center rounded-sm border border-stroke shadow-default dark:border-strokedark dark:bg-boxdark bg-stroke">
<div className="p-4">
<img className="rounded-full"
src={ player.avatar }
alt="Player"/>
</div>
<div className="flex flex-col p-2 align-center items-center">
<h4 className="mb-3 text-xl font-semibold text-black dark:text-white">
{ player.username }
<UserIcons flags={ player.flags }/>
{ isUnmodified && index === 0 && <FaCrown className={ `inline text-gold ml-1` }/> }
{ isUnmodified && index === 1 && <FaCrown className={ `inline text-silver ml-1` }/> }
{ isUnmodified && index === 2 && <FaCrown className={ `inline text-bronze ml-1` }/> }
</h4>
{
queryType === "train" ?
<>
<p className="break-words">{ t("leaderboard.train.distance", { distance: (player.trainDistance / 1000).toFixed(2) }) }</p>
<p className="break-words">{ t("leaderboard.train.points", { points: player.trainPoints }) }</p>
</>
:
<>
<p>{ t("leaderboard.station.time", { time: formatTime(player.dispatcherTime) }) }</p>
</>
}
</div>
<div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap sm:flex-nowrap mt-auto mb-2">
<Link to={ "/profile/" + 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 ${ player.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ player.flags.includes("private") ? { pointerEvents: "none" } : undefined }>{ t("log.buttons.profile") }</Link>
</div>
</div>;
}) }
</div>
);
};

View File

@ -0,0 +1,114 @@
/*
* 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 { Dispatch, SetStateAction } from "react";
import { FlexArrowIcon } from "../../mini/icons/ArrowIcon.tsx";
import { UserIcons } from "../../mini/icons/UserIcons.tsx";
export const LeaderboardTrainTable = ({ 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 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>
{ 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> <UserIcons
flags={ train.flags }/>
</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 ${ train.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ train.flags.includes("private") ? { pointerEvents: "none" } : undefined }
>
{ t("leaderboard.profile") }
</Link>
</div>
</div>
)) }
</div>
</div>
)
;
};

View File

@ -0,0 +1,87 @@
/*
* 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 { UserIcons } from "../../mini/util/UserIcons.tsx";
export const StationTable = ({ 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> <UserIcons
flags={ station.flags }/>
</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 ${ station.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ station.flags.includes("private") ? { pointerEvents: "none" } : undefined }
>
{ t("leaderboard.profile") }
</Link>
</div>
</div>
)) }
</div>
</div>
);
};

View File

@ -0,0 +1,115 @@
/*
* 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 { Dispatch, SetStateAction } from "react";
import { FlexArrowIcon } from "../../mini/icons/ArrowIcon.tsx";
import { UserIcons } from "../../mini/util/UserIcons.tsx";
export const TrainTable = ({ 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-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>
</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> <UserIcons
flags={ train.flags }/>
</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 ${ train.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ train.flags.includes("private") ? { pointerEvents: "none" } : undefined }
>
{ t("leaderboard.profile") }
</Link>
</div>
</div>
)) }
</div>
</div>
)
;
};

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -44,11 +44,9 @@ export const StationLog = ({ data }: { data: TLogStationData }) =>
className="overflow-hidden rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="px-4 pt-6 text-center lg:pb-8 xl:pb-11.5">
<div
className="mx-auto max-w-44 rounded-full">
<div className="relative">
<img className="rounded-full"
src={ data.player.avatar }
alt="Player"/>
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.player.avatar } alt="profile"/>
</div>
</div>
<div className="mt-4">

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -44,11 +44,9 @@ export const TrainLog = ({ data }: { data: TLogTrainData }) =>
className="overflow-hidden rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="px-4 pt-6 text-center lg:pb-8 xl:pb-11.5">
<div
className="mx-auto max-w-44 rounded-full">
<div className="relative">
<img className="rounded-full"
src={ data.player.avatar }
alt="Player"/>
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.player.avatar } alt="profile"/>
</div>
</div>
<div className="mt-4">

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -19,8 +19,6 @@ import { Link } from "react-router-dom";
import dayjs from "dayjs";
import { TStationRecord } from "../../../types/station.ts";
import { UserIcons } from "../../mini/icons/UserIcons.tsx";
import { toast } from "react-toastify";
// setSearchItem: Dispatch<SetStateAction<string>>
export const StationTable = ({ stations }: {
@ -29,59 +27,76 @@ export const StationTable = ({ stations }: {
{
const { t } = useTranslation();
const report = (data: TStationRecord) =>
{
toast.info(t("log.toasts.report"), {
autoClose: 5000,
});
void navigator.clipboard.writeText(`;user: \`${ data.player.username }\`\n;steam: \`https://steamcommunity.com/profiles/${ data.player.id }\`\n;server: \`${ data.server.toUpperCase() }\`\n;left: <t:${ Math.floor(data.leftDate / 1000) }>${ data.joinedDate ? `\n;joined: <t:${ Math.floor(data.joinedDate / 1000) }>` : "" }\n;station: \`${ data.stationName }\`\n;link: https://${ location.hostname }/log/${ data.id }\n\n`);
};
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("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>
<div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4">
{ stations.map((station) =>
{
return <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 }
className="flex flex-col align-center items-center rounded-sm border border-stroke bg-stroke shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="p-4">
<img className="rounded-full"
src={ station.player.avatar }
alt="Player"/>
</div>
<div className="flex flex-col p-2 align-center items-center">
<h4 className="mb-3 text-xl font-semibold text-black dark:text-white">
{ station.player.username }
<UserIcons flags={ station.player.flags }/>
</h4>
<p className={'break-words'}>{ t("log.station.station", { name: station.stationName, short: station.stationShort }) }</p>
<p>{ t("log.station.server", { server: station.server.toUpperCase() }) }</p>
{ station.joinedDate &&
<p>{ t("log.station.joined", { date: dayjs(station.joinedDate).format("HH:mm DD/MM/YYYY") }) }</p> }
<p>{ t("log.station.left", { date: dayjs(station.leftDate).format("HH:mm DD/MM/YYYY") }) }</p>
{ station.joinedDate &&
<p>{ t("log.station.spent", { date: dayjs.duration(station.leftDate - station.joinedDate).format("H[h] m[m]") }) }</p> }
</div>
<div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap mt-auto mb-2">
<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("log.buttons.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("log.buttons.record") }</Link>
<a
onClick={ () => report(station) }
className="cursor-pointer inline-flex items-center justify-center rounded-md bg-danger py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5"
>
{ t("log.buttons.report") }
</a>
<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>
<UserIcons flags={ station.player.flags }/>
</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>;
}) }
</div>
);
};

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -19,7 +19,6 @@ import { Link } from "react-router-dom";
import { TTrainRecord } from "../../../types/train.ts";
import dayjs from "dayjs";
import { UserIcons } from "../../mini/icons/UserIcons.tsx";
import { toast } from "react-toastify";
// setSearchItem: Dispatch<SetStateAction<string>>
export const TrainTable = ({ trains }: {
@ -28,61 +27,95 @@ export const TrainTable = ({ trains }: {
{
const { t } = useTranslation();
const report = (data: TTrainRecord) =>
{
toast.info(t("log.toasts.report"), {
autoClose: 5000,
});
void navigator.clipboard.writeText(`;user: \`${ data.player.username }\`\n;steam: \`https://steamcommunity.com/profiles/${ data.player.id }\`\n;server: \`${ data.server.toUpperCase() }\`\n;left: <t:${ Math.floor(data.leftDate / 1000) }>${ data.joinedDate ? `\n;joined: <t:${ Math.floor(data.joinedDate / 1000) }>` : "" }\n;train: \`${ data.trainNumber }\`\n;link: https://${ location.hostname }/log/${ data.id }\n\n`);
};
return (
<div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4">
{ trains.map((train) =>
{
return <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>
</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 }
className="flex flex-col align-center items-center rounded-sm border border-stroke bg-stroke shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="p-4">
<img className="rounded-full"
src={ train.player.avatar }
alt="Player"/>
</div>
<div className="flex flex-col p-2 align-center items-center">
<h4 className="mb-3 text-xl font-semibold text-black dark:text-white">
{ train.player.username }
<UserIcons flags={ train.player.flags }/>
</h4>
<p>{ t("log.train.train", { name: train.trainName, number: train.trainNumber }) }</p>
<p>{ t("log.train.server", { server: train.server.toUpperCase() }) }</p>
{ train.joinedDate &&
<p>{ t("log.train.joined", { date: dayjs(train.joinedDate).format("HH:mm DD/MM/YYYY") }) }</p> }
<p>{ t("log.train.left", { date: dayjs(train.leftDate).format("HH:mm DD/MM/YYYY") }) }</p>
{ train.joinedDate &&
<p>{ t("log.train.spent", { date: dayjs.duration(train.leftDate - train.joinedDate).format("H[h] m[m]") }) }</p> }
<p>{ t("log.train.distance", { distance: train.distance ? (train.distance / 1000).toFixed(2) : "--" }) }</p>
<p>{ t("log.train.points", { points: train.points || "--" }) }</p>
</div>
<div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap mt-auto mb-2">
<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("log.buttons.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("log.buttons.record") }</Link>
<a
onClick={ () => report(train) }
className="cursor-pointer inline-flex items-center justify-center rounded-md bg-danger py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5"
>
{ t("log.buttons.report") }
</a>
<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>
<UserIcons flags={ train.player.flags }/>
</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>;
}) }
</div>
);
};

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -87,7 +87,7 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
className="overflow-hidden rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="px-4 pt-6 text-center lg:pb-8 xl:pb-11.5">
<div
className="mx-auto max-w-44 rounded-full">
className="mx-auto w-full max-w-30 rounded-full p-1 sm:h-44 sm:max-w-44">
<div className="relative rounded-full">
<img className="rounded-full" src={ data.player.avatar } alt="profile"/>
{ data.active &&

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -18,36 +18,60 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { UserIcons } from "../../mini/icons/UserIcons.tsx";
import { TProfilePlayer } from "../../../types/profile.ts";
// import { formatTime } from "../../../util/time.ts";
export const ProfilesTable = ({ profiles }: { profiles: TProfilePlayer[] }) =>
{
const { t } = useTranslation();
return (
<div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4">
{ profiles.map((player) =>
{
return <div
className="flex flex-col align-center items-center rounded-sm border border-stroke shadow-default dark:border-strokedark dark:bg-boxdark bg-stroke ">
<div className="p-4">
<img className="rounded-full" src={ player.avatar } alt="Player"/>
<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">
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("profiles.user") }
</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("profiles.actions") }
</h5>
</div>
<div className="flex flex-col p-2 align-center items-center">
<h4 className="mb-3 text-xl font-semibold text-black dark:text-white">
{ player.username }
<UserIcons flags={ player.flags }/>
</h4>
</div>
<div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap sm:flex-nowrap mt-auto mb-2">
<Link to={ "/profile/" + 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 ${ player.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ player.flags.includes("private") ? { pointerEvents: "none" } : undefined }>{ t("log.buttons.profile") }</Link>
{ profiles.map((profile, key) => (
<div
className={ `grid grid-cols-2 ${ profiles.length === (key + 1) // todo: ...
? ""
: "border-b border-stroke dark:border-strokedark"
}` }
key={ profile.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/" + profile.id }
className="color-orchid">{ profile.username }</Link> <UserIcons
flags={ profile.flags }/>
</p>
</div>
</div>;
}) }
<div className="hidden items-center justify-center p-2.5 sm:flex xl:p-5">
<Link
to={ "/profile/" + profile.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 ${ profile.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ profile.flags.includes("private") ? { pointerEvents: "none" } : undefined }
>
{ t("profiles.profile") }
</Link>
</div>
</div>
)) }
</div>
</div>
);
};

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -255,6 +255,20 @@ span.flatpickr-weekday,
@apply bg-primary border-primary dark:border-primary;
}
.custom-input-date::-webkit-calendar-picker-indicator {
background-position: center;
background-repeat: no-repeat;
background-size: 20px;
}
.custom-input-date-1::-webkit-calendar-picker-indicator {
background-image: url(./images/icon/icon-calendar.svg);
}
.custom-input-date-2::-webkit-calendar-picker-indicator {
background-image: url(./images/icon/icon-arrow-down.svg);
}
[x-cloak] {
display: none !important;
}

View File

@ -1,20 +1,3 @@
/*
* Copyright (C) 2025 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 { useContext, createContext, ReactNode } from "react";
import useSWR from "swr";
import { get } from "../util/fetcher.ts";

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -35,7 +35,7 @@ function useLocalStorage<T>(
return item ? JSON.parse(item) : initialValue;
} catch
{
return (item && item !== "undefined") ? item : initialValue;
return item ? item : initialValue;
}
}
} catch (error)

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -19,7 +19,6 @@ import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import translationsInEng from "./languages/en.json";
import translationsInPl from "./languages/pl.json";
import translationsInCs from "./languages/cs.json";
const resources = {
en: {
@ -28,9 +27,6 @@ const resources = {
pl: {
translation: translationsInPl,
},
cs: {
translation: translationsInCs,
}
};
void i18n
@ -39,11 +35,10 @@ void i18n
.init({
resources,
debug: false,
fallbackLng: (code: string) => {
if (code.includes('pl')) return 'pl'; // Polish
if (resources as any['cs'] && code.includes('cs')) return 'cs'; // Czech
fallbackLng: {
"pl-PL": [ "pl" ],
return 'en'; // English
default: [ "en" ],
},
interpolation: {
escapeValue: false,

View File

@ -1,173 +0,0 @@
{
"search": {
"placeholder": "například alekswilc",
"placeholder_with_station": "například alekswilc,Katowice",
"none": "Žádné"
},
"home": {
"stats": {
"trains": "vlaků",
"dispatchers": "výpravčích",
"profiles": "profilů"
},
"title": "Simrail Stats",
"description": "Nejlepší stránka se záznamy a statistikami hráčů SimRail!",
"buttons": {
"project": "Stránka projektu",
"forum": "Stránka na fóru SimRail",
"discord": "Discord server"
},
"footer": {
"license": "Licence:",
"powered": "Založeno na:",
"thanks": "Speciální poděkování: <bahu>BAHU.PRO hosting</bahu>, <simrailelite>Simrail ELITE discord</simrailelite>, komunita SimRail a moje přítelkyně",
"author": "Pro komunitu SimRail vytvořeno s ❤️ uživatelem <anchor>{{author}}</anchor> "
}
},
"notfound": {
"title": "Stránka nenalezena",
"description": "Vypadá to, že ses ztratil...",
"button": "Vrátit se na hlavní stránku"
},
"leaderboard": {
"train": {
"distance": "Ujetá vzdálenost: {{distance}} km",
"points": "Získané body: {{points}}"
},
"station": {
"time": "Čas strávený v režimu výpravčího: {{time}}"
},
"buttons": {
"trainDistance": "Největší ujetá vzdálenost",
"trainPoints": "Nejvyšší počet body",
"dispatcherTime": "Nejvíce hodin nahráno v režimu výpravčího"
}
},
"content_loader": {
"error": {
"header": "Chyba načtení stránky",
"description": "Zkontrolujte vaše internetové připojení a načtěte stránku znovu",
"report": "Nahlásit chybu",
"refresh": "Načíst znovu"
},
"notfound": {
"header": "Nebyla nalezena žádná data",
"description": "Vaše vyhledávání nevrátilo žádné výsledky."
}
},
"profile": {
"stats": {
"distance": "Strojvedoucí",
"time": "Výpravčí"
},
"trains": {
"header": "Statistiky vlaků",
"train": "Vlak",
"distance": "Vzdálenost",
"points": "Body",
"time": "Čas"
},
"stations": {
"header": "Statistiky stanic",
"station": "Stanice",
"time": "Čas"
},
"errors": {
"notfound": {
"title": "Profil nebyl nalezen",
"description": "Profil tohoto hráče nemohl být nalezen, nebo si hráč nastavil soukromý Steam účet."
},
"blacklist": {
"title": "Profil není možné zobrazit",
"description": "Profil hráče nemohl být zobrazen kvůli aktivním činnostem moderátorů."
}
},
"info": "Tyto statistiky jsou shromažďovány od {{date}}.",
"active": {
"train": "Řídí vlak {{train}} na serveru {{server}}",
"station": "Výpravčí ve stanici {{station}} na serveru {{server}}"
}
},
"log": {
"errors": {
"notfound": {
"title": "Podrobnosti nebyly nalezeny",
"description": "Podrobnosti tohoto uživatele nebyly nalezeny."
},
"blacklist": {
"title": "Tyto podrobnosti není možné zobrazit",
"description": "Podrobnosti hráče nemohly být zobrazeny kvůli aktivním činnostem moderátorů."
}
},
"station": {
"header": "Opouští stanici",
"server": "Server: {{server}}",
"station": "Stanice: {{name}} - {{short}}",
"joined": "Čas připojení do stanice: {{date}}",
"left": "Čas odpojení ze stanice: {{date}}",
"spent": "Čas strávený ve stanici: {{date}}"
},
"train": {
"header": "Opouští vlak",
"server": "Server: {{server}}",
"train": "Vlak: {{name}} {{number}}",
"joined": "Čas připojení do vlaku: {{date}}",
"left": "Čas odpojení z vlaku: {{date}}",
"spent": "Čas strávený ve vlaku: {{date}}",
"distance": "Vzdálenost: {{distance}} km",
"points": "Body: {{points}}"
},
"toasts": {
"copied": "Odkaz byl zkopírován!",
"report": "Údaje byly zkopírovány do schránky. Můžete je použít v kanálu #multiplayer-help-requests na oficiálním Discord serveru SimRailu. Nezapomeňte připsat důvod nahlášení!"
},
"buttons": {
"report": "Nahlásit",
"copy": "Zkopírovat odkaz",
"profile": "Profil",
"record": "Zobrazit"
}
},
"sidebar": {
"home": "Hlavní stránka",
"logs": "Záznamy",
"stations": "Stanice",
"trains": "Vlaky",
"leaderboard": "Žebříčky hráčů",
"active_players": "Aktivní hráči",
"info": "INFO",
"admin": "ADMIN",
"logged": "Přihlášen jako {{username}}",
"logout": "Odhlásit se",
"profiles": "Profily"
},
"icons": {
"admin": "Administrátor",
"leaderboard_hidden": "Žebříček je skrytý",
"hidden": "Profil je skrytý"
},
"admin": {
"header": "Akce moderátora",
"hideLeaderboard": {
"modal": {
"title": "Jste si jisti?",
"description": "Tato akce skryje profil uživatele z žebříčků."
},
"button": "Skrýt profil v žebříčku",
"button2": "Zobrazit profil v žebříčku",
"alert": "Profil hráče je skrytý."
},
"hide": {
"modal": {
"title": "Jste si jisti?",
"description": "Tato akce skryje profil uživatele ze zobrazování v žebříčku."
},
"button": "Skrýt profil",
"alert": "Profil hráče je skrytý."
},
"update": {
"button": "Vynutit aktualizaci",
"alert": "Profil je aktualizován!"
}
}
}

View File

@ -1,8 +1,17 @@
{
"_": {
"title": "Simrail Stats",
"popup": {
"ranking": "Data in the rankings are collected from 19.08.2024"
}
},
"preview": {
"title": "Preview version!",
"description": "The site is in version V3-PREVIEW, may contain errors. I will be grateful for reporting all errors on the project page (git.alekswilc.dev) or in a private message on discord - alekswilc. Stay tuned!"
},
"search": {
"placeholder": "ex. alekswilc",
"placeholder_with_station": "ex. alekswilc,Katowice",
"none": "None"
"placeholder": "Type to search.",
"tip": "You can search for multiple data using a comma, ex. pl2,alekswilc (server name, username)"
},
"home": {
"stats": {
@ -14,8 +23,7 @@
"description": "Simrail Stats - The best SimRail logs and statistics site!",
"buttons": {
"project": "Project page",
"forum": "Forum page",
"discord": "Discord server"
"forum": "Forum Page"
},
"footer": {
"license": "License:",
@ -29,19 +37,37 @@
"description": "It seems you're lost.",
"button": "Return to homepage."
},
"profiles": {
"user": "Player",
"profile": "Profile",
"actions": "Actions"
},
"leaderboard": {
"train": {
"distance": "Driven distance: {{distance}}km",
"points": "Earned points: {{points}}"
"user": "Player",
"time": "Time",
"distance": "Distance",
"points": "Points",
"profile": "Profile",
"actions": "Actions"
},
"station": {
"time": "Time spent as a dispatcher: {{time}}"
"active": {
"server": "Server",
"user": "Player",
"train": "Train",
"station": "Station",
"profile": "Profile",
"actions": "Actions"
},
"buttons": {
"trainDistance": "Longest distance traveled",
"trainPoints": "Most points",
"dispatcherTime": "Most hours as dispatcher"
}
"logs": {
"user": "Player",
"time": "Time",
"distance": "Distance",
"points": "Points",
"profile": "Profile",
"record": "Record",
"train": "Train",
"actions": "Actions",
"station": "Station"
},
"content_loader": {
"error": {
@ -119,13 +145,12 @@
},
"toasts": {
"copied": "Link copied to clipboard!",
"report": "The data has been copied to your clipboard, you can use it to submit to the #multiplayer-help-requests channel on the official simrail Discord server. Don't forget to add a reason for the report!"
"report": "The outpost exit data has been copied to your clipboard, you can use it to submit to the #multiplayer-help-requests channel on the official simrail Discord server. Don't forget to add a reason for the report!"
},
"buttons": {
"report": "Report",
"copy": "Copy link",
"profile": "Profile",
"record": "Record"
"profile": "Profile"
}
},
"sidebar": {
@ -139,7 +164,7 @@
"admin": "ADMIN",
"logged": "Logged as {{username}}",
"logout": "Log out",
"profiles": "Players"
"profiles": "Profiles"
},
"icons": {
"admin": "Administrator",

View File

@ -1,8 +1,17 @@
{
"_": {
"title": "Simrail Stats",
"popup": {
"ranking": "Dane w rankingach zbierane są od 19.08.2024"
}
},
"preview": {
"title": "Wersja preview!",
"description": "Strona znajduje się w wersji V3-PREVIEW, może zawierać błędy. Będe wdzieczny za zgłaszanie wszystkich błędów na stronie projektu (git.alekswilc.dev) lub w wiadomości prywatnej na discordzie - alekswilc. Stay tuned!"
},
"search": {
"placeholder": "np. alekswilc",
"placeholder_with_station": "np. alekswilc,Katowice",
"none": "Brak"
"placeholder": "Wpisz, aby wyszukać",
"tip": "Możesz wyszukiwać wiele danych używając przecinka, np pl2,alekswilc (nazwa serwera, nazwa użytkownika)"
},
"home": {
"stats": {
@ -14,8 +23,7 @@
"description": "Najbardziej rozbudowana strona z logami i statystykami gry SimRail!",
"buttons": {
"project": "Strona projektu",
"forum": "Strona na forum",
"discord": "Serwer discord"
"forum": "Strona na forum"
},
"footer": {
"license": "Licencja:",
@ -30,18 +38,36 @@
"button": "Wróć do strony głównej"
},
"leaderboard": {
"train": {
"distance": "Przejechany dystans: {{distance}}km",
"points": "Zdobyte punkty: {{points}}"
"user": "Gracz",
"time": "Czas",
"distance": "Dystans",
"points": "Punkty",
"profile": "Profil",
"actions": "Akcje"
},
"station": {
"time": "Spędzony czas jako dyżurny ruchu: {{time}}"
"profiles": {
"user": "Gracz",
"profile": "Profil",
"actions": "Akcje"
},
"buttons": {
"trainDistance": "Największy przejechany dystans",
"trainPoints": "Najwięcej punktów",
"dispatcherTime": "Najwięcej godzin na nastawni"
}
"active": {
"server": "Serwer",
"user": "Gracz",
"train": "Pociąg",
"station": "Stacja",
"profile": "Profil",
"actions": "Akcje"
},
"logs": {
"user": "Gracz",
"time": "Czas",
"distance": "Dystans",
"points": "Punkty",
"profile": "Profil",
"record": "Więcej",
"train": "Pociąg",
"actions": "Akcje",
"station": "Stacja"
},
"content_loader": {
"error": {
@ -119,15 +145,19 @@
},
"toasts": {
"copied": "Skopiowano link do schowka!",
"report": "Do schowka skopiowano dane, możesz ich użyć do wysłania na kanale #multiplayer-help-requests na oficjalnym serwerze Discord gry simrail. Nie zapomnij dodać powodu zgłoszenia!"
"report": "Do schowka skopiowano dane wyjścia z posterunku, możesz ich użyć do wysłania na kanale #multiplayer-help-requests na oficjalnym serwerze Discord gry simrail. Nie zapomnij dodać powodu zgłoszenia!"
},
"buttons": {
"report": "Zgłoś",
"copy": "Kopiuj link",
"profile": "Profil",
"record": "Rekord"
"profile": "Profil"
}
},
"icons": {
"admin": "Administrator",
"leaderboard_hidden": "Tablica wyników ukryta",
"hidden": "Profil ukryty"
},
"sidebar": {
"home": "Strona główna",
"logs": "Logi",
@ -139,12 +169,7 @@
"admin": "ADMIN",
"logged": "Zalogowano jako {{username}}",
"logout": "Wyloguj",
"profiles": "Gracze"
},
"icons": {
"admin": "Administrator",
"leaderboard_hidden": "Tablica wyników ukryta",
"hidden": "Profil ukryty"
"profiles": "Profile"
},
"admin": {
"header": "Akcje moderacyjne",

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -21,10 +21,12 @@ import App from "./App";
import "./css/style.css";
import "./css/satoshi.css";
import "flatpickr/dist/flatpickr.min.css";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime.js";
import duration from "dayjs/plugin/duration.js";
dayjs.extend(duration);
dayjs.extend(relativeTime);

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -17,7 +17,7 @@
import { ChangeEvent, useEffect, useState } from "react";
import { useDebounce } from "use-debounce";
import { SearchWithServerSelector } from "../../components/mini/util/Search.tsx";
import { Search } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import { get } from "../../util/fetcher.ts";
import useSWR from "swr";
@ -33,41 +33,38 @@ export const ActiveStationsPlayers = () =>
const { data, error, isLoading } = useSWR(`/active/station/?${ params.toString() }`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchParams, setSearchParams ] = useSearchParams();
const [ searchItem, setSearchItem ] = useState(searchParams.get("query") ?? "");
const [ server, setServer ] = useState(searchParams.get("server") ?? "");
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("query", searchValue);
server && params.set("server", server);
searchValue && params.set("q", searchValue);
setSearchParams(params.toString());
setParams(params);
}, [ searchValue, server ]);
}, [ searchValue ]);
useEffect(() =>
{
setSearchItem(searchParams.get("query") ?? "");
setServer(searchParams.get("server") ?? "");
}, []);
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">
<SearchWithServerSelector handleInputChange={ handleInputChange } searchItem={ searchItem }
servers={ data?.code === 200 ? data?.data?.servers : [] }
server={ server } setServer={ setServer }/>
<Search handleInputChange={ handleInputChange } searchItem={ searchItem }/>
<>
{ error && <LoadError/> }
{ isLoading && <ContentLoader/> }
@ -78,10 +75,9 @@ export const ActiveStationsPlayers = () =>
{ data && data.code === 200 && data.data && !!data?.data?.records?.length &&
<ActiveStationTable stations={ data.data.records }/> }
</>
</div>
</>
)
;
);
};

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -17,7 +17,7 @@
import { ChangeEvent, useEffect, useState } from "react";
import { useDebounce } from "use-debounce";
import { SearchWithServerSelector } from "../../components/mini/util/Search.tsx";
import { Search } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { get } from "../../util/fetcher.ts";
@ -32,27 +32,27 @@ export const ActiveTrainPlayers = () =>
const { data, error, isLoading } = useSWR(`/active/train/?${ params.toString() }`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchParams, setSearchParams ] = useSearchParams();
const [ searchItem, setSearchItem ] = useState(searchParams.get("query") ?? "");
const [ server, setServer ] = useState(searchParams.get("server") ?? "");
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("query", searchValue);
server && params.set("server", server);
searchValue && params.set("q", searchValue);
setSearchParams(params.toString());
setParams(params);
}, [ searchValue, server ]);
}, [ searchValue ]);
useEffect(() =>
{
setSearchItem(searchParams.get("query") ?? "");
setServer(searchParams.get("server") ?? "");
}, []);
setSearchItem(searchParams.get("q") ?? "");
}, [ searchParams ]);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) =>
{
@ -64,9 +64,7 @@ export const ActiveTrainPlayers = () =>
return (
<>
<div className="flex flex-col gap-10">
<SearchWithServerSelector handleInputChange={ handleInputChange } searchItem={ searchItem }
servers={ data?.code === 200 ? data?.data?.servers : [] }
server={ server } setServer={ setServer }/>
<Search handleInputChange={ handleInputChange } searchItem={ searchItem }/>
<>
{ error && <LoadError/> }

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,127 +0,0 @@
/*
* Copyright (C) 2025 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 { LeaderboardTable } from "../../components/pages/leaderboard/LeaderboardTable.tsx";
import { useDebounce } from "use-debounce";
import { SearchWithChild } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import { get } 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 { Paginator } from "../../components/mini/util/Paginator.tsx";
export const Leaderboard = () =>
{
const [ searchParams, setSearchParams ] = useSearchParams();
const [ params, setParams ] = useState(new URLSearchParams());
const [ queryType, setQueryType ] = useState(searchParams.get("type") ?? "train");
const { data, error, isLoading } = useSWR(`/leaderboard/${ queryType }/?${ params.toString() }`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchItem, setSearchItem ] = useState(searchParams.get("query") ?? "");
const [ sortBy, setSortBy ] = useState(searchParams.get("sort_by") ?? "distance");
const [ searchValue ] = useDebounce(searchItem, 500);
const [ page, setPage ] = useState(parseInt(searchParams.get("page") as string) || 1);
useEffect(() =>
{
setSearchItem(searchParams.get("query") ?? "");
setSortBy(searchParams.get("sort_by") ?? "distance");
setPage(parseInt(searchParams.get("page") as string) || 1);
}, []);
useEffect(() =>
{
const params = new URLSearchParams();
searchValue && params.set("query", searchValue);
sortBy && params.set("sort_by", sortBy);
queryType && params.set("type", queryType);
page && params.set("page", page.toString());
setSearchParams(params.toString());
setParams(params);
}, [ searchValue, sortBy, page ]);
useEffect(() =>
{
setPage(1);
}, [ searchValue, sortBy ]);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) =>
{
setSearchItem(e.target.value);
};
const { t } = useTranslation();
return (
<>
<div className="flex flex-col gap-10">
<SearchWithChild handleInputChange={ handleInputChange } searchItem={ searchItem }>
<div className="items-center justify-center flex gap-2 flex-wrap sm:flex-nowrap">
<a
onClick={ () =>
{
setSortBy("distance");
setQueryType("train");
} }
className={ `cursor-pointer 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 grow ${ (queryType === "train" && sortBy === "distance") ? "bg-opacity-70" : "" }` }>{ t("leaderboard.buttons.trainDistance") }</a>
<a
onClick={ () =>
{
setSortBy("points");
setQueryType("train");
} }
className={ `cursor-pointer 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 grow ${ (queryType === "train" && sortBy === "points") ? "bg-opacity-70" : "" }` }>{ t("leaderboard.buttons.trainPoints") }</a>
<a
onClick={ () =>
{
setSortBy(undefined!);
setQueryType("station");
} }
className={ `cursor-pointer inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-70 lg:px-4 xl:px-5 grow ${ queryType === "station" ? "bg-opacity-50" : "" }` }>{ t("leaderboard.buttons.dispatcherTime") }</a>
</div>
</SearchWithChild>
<>
{ 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 &&
<>
<LeaderboardTable list={ data.data.records } queryType={ queryType }
isUnmodified={ page === 1 && !searchValue }/>
<Paginator page={ page } pages={ data.data.pages } setPage={ setPage }/>
</>
}
</>
</div>
</>
);
};

View File

@ -0,0 +1,84 @@
/*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* See LICENSE for more.
*/
import { ChangeEvent, useEffect, useState } from "react";
import { LeaderboardStationTable } from "../../components/pages/leaderboard/LeaderboardStationTable.tsx";
import { useDebounce } from "use-debounce";
import { Search } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import { get } 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 [ params, setParams ] = useState(new URLSearchParams());
const { data, error, isLoading } = useSWR(`/leaderboard/station/?${ params.toString() }`, get, { 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 &&
<LeaderboardStationTable stations={ data.data.records }/> }
</>
</div>
</>
);
};

View File

@ -0,0 +1,88 @@
/*
* 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 { LeaderboardTrainTable } from "../../components/pages/leaderboard/LeaderboardTrainTable.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 { get } 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 [ params, setParams ] = useState(new URLSearchParams());
const { data, error, isLoading } = useSWR(`/leaderboard/train/?${ params.toString() }`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchParams, setSearchParams ] = useSearchParams();
const [ searchItem, setSearchItem ] = useState(searchParams.get("q") ?? "");
const [ sortBy, setSortBy ] = useState(searchParams.get("s") ?? "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") ?? "");
setSortBy(searchParams.get("s") ?? "distance");
}, [ 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 &&
<LeaderboardTrainTable trains={ data?.data?.records } setSortBy={ setSortBy }
sortBy={ sortBy }/> }
</>
</div>
</>
);
};

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* 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
@ -17,14 +17,13 @@
import { ChangeEvent, useEffect, useState } from "react";
import { StationTable } from "../../components/pages/logs/StationTable.tsx";
import { useDebounce } from "use-debounce";
import { SearchWithServerSelector } from "../../components/mini/util/Search.tsx";
import { Search } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import useSWR from "swr";
import { get } 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";
import { Paginator } from "../../components/mini/util/Paginator.tsx";
export const StationLogs = () =>
{
@ -32,28 +31,25 @@ export const StationLogs = () =>
const { data, error, isLoading } = useSWR(`/stations/?${ params.toString() }`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchParams, setSearchParams ] = useSearchParams();
const [ searchItem, setSearchItem ] = useState(searchParams.get("query") ?? "");
const [ server, setServer ] = useState(searchParams.get("server") ?? "");
const [ page, setPage ] = useState(parseInt(searchParams.get("page") as string) || 1);
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("query", searchValue);
server && params.set("server", server);
page && params.set("page", page.toString());
searchValue && params.set("q", searchValue);
setSearchParams(params.toString());
setParams(params);
}, [ searchValue, server, page ]);
}, [ searchValue ]);
useEffect(() =>
{
setSearchItem(searchParams.get("query") ?? "");
setServer(searchParams.get("server") ?? "");
setPage(parseInt(searchParams.get("page") as string) || 1);
}, []);
setSearchItem(searchParams.get("q") ?? "");
}, [ searchParams ]);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) =>
{
@ -65,10 +61,7 @@ export const StationLogs = () =>
return (
<>
<div className="flex flex-col gap-10">
<SearchWithServerSelector handleInputChange={ handleInputChange } searchItem={ searchItem }
servers={ data?.code === 200 ? data?.data?.servers : [] }
server={ server } setServer={ setServer }
/>
<Search handleInputChange={ handleInputChange } searchItem={ searchItem }/>
<>
{ error && <LoadError/> }
@ -79,11 +72,7 @@ export const StationLogs = () =>
description={ t("content_loader.notfound.description") }/> }
{ data && data.code === 200 && !!data?.data?.records?.length &&
<>
<StationTable stations={ data.data.records }/>
<Paginator page={ page } pages={ data.data.pages } setPage={ setPage }/>
</>
}
<StationTable stations={ data.data.records }/> }
</>
</div>
</>

Some files were not shown because too many files have changed in this diff Show More