Compare commits

...

83 Commits
main ... main

Author SHA1 Message Date
996f16a313
Merge pull request 'update license year.' (#119) from license-year into main
Reviewed-on: simrail/simrail.pro#119
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2025-04-11 16:52:41 +02:00
0f8b1a4729
update license year. 2025-04-11 16:48:47 +02:00
badefb0f7f
Merge pull request 'build(): drone CI build' (#118) from drone-ci into main
Reviewed-on: simrail/simrail.pro#118
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2025-03-27 16:57:27 +01:00
2ba037fab5
build(): drone CI build 2025-03-27 16:56:14 +01:00
637134081a
Merge pull request 'build(): drone CI' (#117) from drone-ci into main
Reviewed-on: simrail/simrail.pro#117
2025-03-27 16:54:39 +01:00
96c17ffe85
build(): drone CI 2025-03-27 16:54:21 +01:00
8db158afe9
Merge pull request 'build(): drone CI' (#116) from drone-ci into main
Reviewed-on: simrail/simrail.pro#116
2025-03-27 16:51:58 +01:00
6015349c6f
build(): drone CI 2025-03-27 16:51:18 +01:00
cd191a6f61
Merge pull request 'build(): drone CI' (#115) from drone-ci into main
Reviewed-on: simrail/simrail.pro#115
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2025-03-27 16:50:37 +01:00
08bfe767bb
build(): drone CI 2025-03-27 16:49:36 +01:00
6a4ebf56c4
Merge pull request 'fix(): refactor frontend build process to optimize site performance' (#114) from feat-1 into main
Reviewed-on: simrail/simrail.pro#114
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2025-03-05 14:40:36 +01:00
1e4cafc07f
fix(): refactor frontend build process to optimize site performance 2025-03-05 14:37:07 +01:00
623bdd8d42
Merge pull request 'fix(): rollbacl' (#113) from feat-1 into main
Reviewed-on: simrail/simrail.pro#113
2025-03-04 16:02:11 +01:00
51b3ff2e22
fix(): rollbacl 2025-03-04 16:01:56 +01:00
816ee5c454
Merge pull request 'fix(): rollbacl' (#112) from feat-1 into main
Reviewed-on: simrail/simrail.pro#112
2025-03-04 16:01:23 +01:00
9a3f004c72
fix(): rollbacl 2025-03-04 16:01:07 +01:00
f528da171b
Merge pull request 'fix(): rollbacl' (#111) from feat-1 into main
Reviewed-on: simrail/simrail.pro#111
2025-03-04 16:00:00 +01:00
b23921a28c
fix(): rollbacl 2025-03-04 15:59:42 +01:00
e045ded046
Merge pull request 'fix(): rollbacl' (#110) from feat-1 into main
Reviewed-on: simrail/simrail.pro#110
2025-03-04 15:59:19 +01:00
859b7ab3cc
fix(): rollbacl 2025-03-04 15:58:58 +01:00
34432e9622
Merge pull request 'fix(): rollbacl' (#109) from feat-1 into main
Reviewed-on: simrail/simrail.pro#109
2025-03-04 15:54:47 +01:00
3af371703b
fix(): rollbacl 2025-03-04 15:54:28 +01:00
fcef4e428e
Merge pull request 'fix(): rollbacl' (#108) from feat-1 into main
Reviewed-on: simrail/simrail.pro#108
2025-03-04 15:49:46 +01:00
ca8e270c5e
fix(): rollbacl 2025-03-04 15:49:30 +01:00
a9d35c604e
Merge pull request 'fix(): rollbacl' (#107) from feat-1 into main
Reviewed-on: simrail/simrail.pro#107
2025-03-04 15:48:40 +01:00
8c24a29de2
fix(): rollbacl 2025-03-04 15:48:17 +01:00
e079a1177e
Merge pull request 'fix(): fix nginx' (#106) from feat-1 into main
Reviewed-on: simrail/simrail.pro#106
2025-03-04 15:46:47 +01:00
7bf053e452
fix(): fix nginx 2025-03-04 15:46:09 +01:00
328f665c5c
Merge pull request 'fix(): fix nginx' (#105) from feat-1 into main
Reviewed-on: simrail/simrail.pro#105
2025-03-04 15:45:21 +01:00
05fe82eff1
fix(): fix nginx 2025-03-04 15:45:05 +01:00
3143ce1058
Merge pull request 'fix(): fix nginx' (#104) from feat-1 into main
Reviewed-on: simrail/simrail.pro#104
2025-03-04 15:44:06 +01:00
85d18190a7
fix(): fix nginx 2025-03-04 15:43:51 +01:00
45434f5a2d
Merge pull request 'fix(): fix nginx' (#103) from feat-1 into main
Reviewed-on: simrail/simrail.pro#103
2025-03-04 15:41:16 +01:00
922b7ea633
fix(): fix nginx 2025-03-04 15:40:57 +01:00
f0f01ccda1
Merge pull request 'fix(): fix nginx' (#102) from feat-1 into main
Reviewed-on: simrail/simrail.pro#102
2025-03-04 15:38:43 +01:00
947bd5dedc
fix(): fix nginx 2025-03-04 15:38:21 +01:00
a2a9fd25b5
Merge pull request 'fix(): fix nginx' (#101) from feat-1 into main
Reviewed-on: simrail/simrail.pro#101
2025-03-04 15:36:10 +01:00
afdc700a64
fix(): fix nginx 2025-03-04 15:35:54 +01:00
f5ba173b24
Merge pull request 'fix(): fix nginx' (#100) from feat-1 into main
Reviewed-on: simrail/simrail.pro#100
2025-03-04 15:34:53 +01:00
7459649829
fix(): fix nginx 2025-03-04 15:34:37 +01:00
31c1f3fc40
Merge pull request 'fix(): fix nginx' (#99) from feat-1 into main
Reviewed-on: simrail/simrail.pro#99
2025-03-04 15:31:35 +01:00
df1d9df212
fix(): fix nginx 2025-03-04 15:31:03 +01:00
680a18d15a
Merge pull request 'fix(): fix nginx' (#98) from feat-1 into main
Reviewed-on: simrail/simrail.pro#98
2025-03-04 15:30:06 +01:00
40f31ba723
fix(): fix nginx 2025-03-04 15:29:41 +01:00
f9974a3430
Merge pull request 'fix(): rollbacl' (#97) from feat-1 into main
Reviewed-on: simrail/simrail.pro#97
2025-03-04 15:20:36 +01:00
c5249da57e
fix(): rollbacl 2025-03-04 15:20:19 +01:00
85e00e8d52
Merge pull request 'fix(): fix nginx' (#96) from feat-1 into main
Reviewed-on: simrail/simrail.pro#96
2025-03-04 15:16:27 +01:00
5841c77913
fix(): fix nginx 2025-03-04 15:16:03 +01:00
84d2972869
Merge pull request 'fix(): nginx config for spa' (#95) from feat-1 into main
Reviewed-on: simrail/simrail.pro#95
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2025-03-04 15:09:30 +01:00
94407bc7c4
Merge branch 'main' of https://git.alekswilc.dev/simrail/simrail.pro 2025-03-04 15:06:46 +01:00
47695d7e4f
fix(): nginx config for SPA 2025-03-04 15:06:23 +01:00
529d8b8020
Merge pull request 'feat(): docker, fix darkmode' (#93) from feat-1 into main
Reviewed-on: simrail/simrail.pro#93
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2025-02-20 01:39:15 +01:00
3db6ced4d2
feat(): docker, fix darkmode 2025-02-20 01:36:13 +01:00
e5614da846
Merge pull request 'Update packages/backend/src/util/imgproxy.ts' (#92) from alekswilc-patch-1 into main
Reviewed-on: simrail/simrail.pro#92
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2025-02-11 03:15:58 +01:00
71a5b77235
Update packages/backend/src/util/imgproxy.ts 2025-02-11 03:14:44 +01:00
7617738ab2
Merge pull request 'fix(): active trains search' (#90) from v3.0.1 into main
Reviewed-on: simrail/simrail.pro#90
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2025-01-13 02:19:08 +01:00
9c3d3e0767
fix(): fix workflows 2025-01-13 02:18:21 +01:00
294068ee97
fix(): fix workflows 2025-01-13 02:16:42 +01:00
29a7ab3a6b
fix(): fix runner 2025-01-13 02:12:38 +01:00
b4cee2447d
fix(): active trains search 2025-01-13 02:08:56 +01:00
a4091c92e4
Merge pull request 'Update .gitea/workflows/build.yaml' (#89) from alekswilc-patch-1 into main
Reviewed-on: simrail/simrail.pro#89
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2024-12-31 02:29:47 +01:00
77a9540be4
Update .gitea/workflows/build.yaml 2024-12-31 02:29:04 +01:00
f8f5a38add
Update .gitea/workflows/build.yaml 2024-12-31 02:26:37 +01:00
4c7482b919
Update .gitea/workflows/build.yaml 2024-12-31 02:25:37 +01:00
f10c623aa8
Update .gitea/workflows/build.yaml 2024-12-31 02:23:29 +01:00
4e090ed281
Update .gitea/workflows/build.yaml 2024-12-31 02:12:33 +01:00
1b9c616e16
Update .gitea/workflows/build.yaml 2024-12-31 02:07:55 +01:00
7fe575a651
Update .gitea/workflows/build.yaml 2024-12-31 02:07:05 +01:00
f33c54ba26
Update .gitea/workflows/build.yaml 2024-12-31 02:05:54 +01:00
a9094fd1ed
Add .gitea/workflows/build.yaml 2024-12-31 02:04:04 +01:00
47d98aba82
Merge pull request 'fix(): steam ratelimits' (#88) from v3.0.1 into main
Reviewed-on: simrail/simrail.pro#88
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2024-12-30 23:07:43 +01:00
c8efbb92f5
fix(): steam ratelimits 2024-12-30 23:06:25 +01:00
e460fdefe8
Merge pull request 'feat(): Czech language. Thanks to Maty556677' (#87) from v3.0.1 into main
Reviewed-on: simrail/simrail.pro#87
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2024-12-30 21:44:07 +01:00
7ed9df1e9d
feat(): Czech language. Thanks to Maty556677 2024-12-30 21:41:56 +01:00
094e00842a
Merge pull request 'v3.2.1' (#86) from v3.0.1 into main
Reviewed-on: simrail/simrail.pro#86
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2024-12-27 01:50:07 +01:00
608894ee1e
fix(): fix images in profile & log. small backend change related to profile, logs imgproxy and gitutil. 2024-12-27 01:48:11 +01:00
895725dc66
fix(frontend): fixed default state in leaderboard 2024-12-27 01:19:23 +01:00
0275d3e0d1
Merge pull request 'v3.2.2' (#85) from v3.0.1 into main
Reviewed-on: simrail/simrail.pro#85
2024-12-26 23:34:21 +01:00
a46b2237cf
Fixed perf break. 2024-12-26 23:33:52 +01:00
70eced72d2
Merge pull request 'v3.2.1' (#84) from v3.0.1 into main
Reviewed-on: simrail/simrail.pro#84
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2024-12-26 23:29:11 +01:00
0004148cbf
Fixed images size.
Fixed image in profile
2024-12-26 23:28:05 +01:00
cfcf5cdaaf
Merge pull request 'v3.2.0' (#83) from v3.0.1 into main
Reviewed-on: simrail/simrail.pro#83
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2024-12-26 23:15:34 +01:00
b7667304d2
Added pagination
Replaced table with grid
Updated search - added servers button
Updated leaderboard - Added buttons on top & pagination
Added imageproxy for resizing.
2024-12-26 23:06:32 +01:00
117 changed files with 1900 additions and 5248 deletions

31
.drone.yml Normal file
View File

@ -0,0 +1,31 @@
---
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

7
.idea/copyright/profiles_settings.xml generated Normal file
View File

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

View File

@ -0,0 +1,10 @@
<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>

6
.idea/jsLibraryMappings.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?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,7 +4,6 @@
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"build": "yarn workspace backend build && yarn workspace frontend build", "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\"" "start": "concurrently --kill-others-on-fail \"yarn workspace backend start\" \"yarn workspace frontend dev\""
}, },
"workspaces": [ "workspaces": [

View File

@ -0,0 +1,20 @@
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

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

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

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

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -17,8 +17,9 @@
import { Router } from "express"; import { Router } from "express";
import { PipelineStage } from "mongoose"; import { PipelineStage } from "mongoose";
import { IProfile, MProfile } from "../../mongo/profile.js"; import { IProfile, MProfile } from "../../mongo/profile.js";
import { SuccessResponseBuilder } from "../responseBuilder.js"; import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js";
import { escapeRegexString, removeProperties } from "../../util/functions.js"; import { escapeRegexString, removeProperties } from "../../util/functions.js";
import { generateUrl } from "../../util/imgproxy.js";
const generateSearch = (regex: RegExp) => [ const generateSearch = (regex: RegExp) => [
{ {
@ -43,7 +44,17 @@ export class LeaderboardRoute
app.get("/train", async (req, res) => app.get("/train", async (req, res) =>
{ {
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i")); 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 filter: PipelineStage[] = [ const filter: PipelineStage[] = [
{ {
@ -61,23 +72,61 @@ export class LeaderboardRoute
}, },
}); });
const sortBy = sortyByMap[ req.query.s?.toString() ?? "distance" ] ?? sortyByMap.distance; const sortBy = sortyByMap[ req.query.sort_by?.toString() ?? "distance" ] ?? sortyByMap.distance;
const records = await MProfile.aggregate(filter) filter.push({
.sort(sortBy) $sort: sortBy,
.limit(50); });
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);
});
res.json( res.json(
new SuccessResponseBuilder<{ records: Omit<IProfile, "_id" | "__v">[] }>() new SuccessResponseBuilder()
.setCode(200) .setCode(200)
.setData({ records: records.map(x => removeProperties<Omit<IProfile, "_id" | "__v">>(x, [ "_id", "__v" ])) }) .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),
})
.toJSON(), .toJSON(),
); );
}); });
app.get("/station", async (req, res) => app.get("/station", async (req, res) =>
{ {
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i")); 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 filter: PipelineStage[] = [ const filter: PipelineStage[] = [
{ {
@ -94,14 +143,43 @@ export class LeaderboardRoute
}, },
}); });
const records = await MProfile.aggregate(filter) filter.push({
.sort({ dispatcherTime: -1 }) $sort: {
.limit(50); 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);
});
res.json( res.json(
new SuccessResponseBuilder<{ records: Omit<IProfile, "_id" | "__v">[] }>() new SuccessResponseBuilder()
.setCode(200) .setCode(200)
.setData({ records: records.map(x => removeProperties<Omit<IProfile, "_id" | "__v">>(x, [ "_id", "__v" ])) }) .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),
})
.toJSON(), .toJSON(),
); );
}); });

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -19,6 +19,7 @@ import { MTrainLog } from "../../mongo/trainLog.js";
import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js"; import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js";
import { MStationLog } from "../../mongo/stationLog.js"; import { MStationLog } from "../../mongo/stationLog.js";
import { IProfile } from "../../mongo/profile.js"; import { IProfile } from "../../mongo/profile.js";
import { generateUrl } from "../../util/imgproxy.js";
export class LogRoute export class LogRoute
@ -60,7 +61,13 @@ export class LogRoute
return; return;
} }
res.status(200).json(new SuccessResponseBuilder().setCode(200).setData(log.toJSON()));
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());
}); });
return app; return app;

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -19,7 +19,8 @@ import { PipelineStage } from "mongoose";
import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js"; import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js";
import { PlayerUtil } from "../../util/PlayerUtil.js"; import { PlayerUtil } from "../../util/PlayerUtil.js";
import { IProfile, MProfile } from "../../mongo/profile.js"; import { IProfile, MProfile } from "../../mongo/profile.js";
import { escapeRegexString, removeProperties } from "../../util/functions.js"; import { escapeRegexString } from "../../util/functions.js";
import { generateUrl } from "../../util/imgproxy.js";
const generateSearch = (regex: RegExp) => [ const generateSearch = (regex: RegExp) => [
{ {
@ -111,6 +112,11 @@ export class ProfilesRoute
} }
if (process.env.IMGPROXY_KEY)
{
player.avatar = generateUrl(player.avatar, "rs:auto:256:256:1/f:png");
}
res.json( res.json(
new SuccessResponseBuilder() new SuccessResponseBuilder()
.setCode(200) .setCode(200)
@ -127,6 +133,16 @@ export class ProfilesRoute
{ {
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i")); 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[] = [ const filter: PipelineStage[] = [
{ {
$match: { $match: {
@ -143,13 +159,38 @@ export class ProfilesRoute
}, },
}); });
const records = await MProfile.aggregate(filter) filter.push({
.limit(50); $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);
});
res.json( res.json(
new SuccessResponseBuilder<{ records: Omit<IProfile, "_id" | "__v">[] }>() new SuccessResponseBuilder()
.setCode(200) .setCode(200)
.setData({ records: records.map(x => removeProperties<Omit<IProfile, "_id" | "__v">>(x, [ "_id", "__v" ])) }) .setData({
records: records[ 0 ].data,
pages: Math.floor((records[ 0 ]?.count?.[ 0 ]?.count ?? 0) / limit),
})
.toJSON(), .toJSON(),
); );
}); });

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -18,8 +18,9 @@ import { Router } from "express";
import { IStationLog, MStationLog } from "../../mongo/stationLog.js"; import { IStationLog, MStationLog } from "../../mongo/stationLog.js";
import { PipelineStage } from "mongoose"; import { PipelineStage } from "mongoose";
import { escapeRegexString } from "../../util/functions.js"; import { escapeRegexString } from "../../util/functions.js";
import { SuccessResponseBuilder } from "../responseBuilder.js"; import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js";
import { MProfile } from "../../mongo/profile.js"; import { IProfile, MProfile } from "../../mongo/profile.js";
import { generateUrl } from "../../util/imgproxy.js";
const generateSearch = (regex: RegExp) => [ const generateSearch = (regex: RegExp) => [
{ {
@ -47,7 +48,19 @@ export class StationsRoute
app.get("/", async (req, res) => app.get("/", async (req, res) =>
{ {
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i")); 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 filter: PipelineStage[] = []; const filter: PipelineStage[] = [];
s && filter.push({ s && filter.push({
@ -58,16 +71,56 @@ export class StationsRoute
}, },
}); });
const records = await MStationLog.aggregate(filter) server && filter.push({
.sort({ leftDate: -1 }) $match: {
.limit(30); server: {
$regex: new RegExp(escapeRegexString(server), "i"),
},
},
});
await MProfile.populate(records, { path: "player" }); 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);
});
res.json( res.json(
new SuccessResponseBuilder<{ records: IStationLog[] }>() new SuccessResponseBuilder()
.setCode(200) .setCode(200)
.setData({ records }) .setData({
records: records[ 0 ].data,
pages: Math.floor((records[ 0 ]?.count?.[ 0 ]?.count ?? 0) / limit),
servers: Object.keys(client.stations),
})
.toJSON(), .toJSON(),
); );
}); });

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -46,7 +46,6 @@ export class StatsRoute
); );
}); });
return app; return app;
} }
} }

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -17,9 +17,10 @@
import { Router } from "express"; import { Router } from "express";
import { PipelineStage } from "mongoose"; import { PipelineStage } from "mongoose";
import { ITrainLog, MTrainLog } from "../../mongo/trainLog.js"; import { ITrainLog, MTrainLog } from "../../mongo/trainLog.js";
import { SuccessResponseBuilder } from "../responseBuilder.js"; import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js";
import { escapeRegexString } from "../../util/functions.js"; import { escapeRegexString } from "../../util/functions.js";
import { MProfile } from "../../mongo/profile.js"; import { IProfile, MProfile } from "../../mongo/profile.js";
import { generateUrl } from "../../util/imgproxy.js";
const generateSearch = (regex: RegExp) => [ const generateSearch = (regex: RegExp) => [
{ {
@ -44,10 +45,20 @@ export class TrainsRoute
app.get("/", async (req, res) => app.get("/", async (req, res) =>
{ {
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i")); const s = req.query.query?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
const server = req.query.server?.toString();
const filter: PipelineStage[] = []; 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({ s && filter.push({
$match: { $match: {
@ -57,17 +68,55 @@ export class TrainsRoute
}, },
}); });
const records = await MTrainLog.aggregate(filter) server && filter.push({
.sort({ leftDate: -1 }) $match: {
.limit(30); server: {
$regex: new RegExp(escapeRegexString(server), "i"),
},
},
});
await MProfile.populate(records, { path: "player" }); 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);
});
res.json( res.json(
new SuccessResponseBuilder<{ records: ITrainLog[] }>() new SuccessResponseBuilder()
.setCode(200) .setCode(200)
.setData({ .setData({
records, records: records[ 0 ].data,
pages: Math.floor((records[ 0 ]?.count?.[ 0 ]?.count ?? 0) / limit),
servers: Object.keys(client.stations),
}) })
.toJSON(), .toJSON(),
); );

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -24,6 +24,7 @@ import { TrainsModule } from "./modules/trains.js";
import { Server, Station, Train } from "@simrail/types"; import { Server, Station, Train } from "@simrail/types";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { TMProfile } from "./mongo/profile.js"; import { TMProfile } from "./mongo/profile.js";
import { GitUtil } from "./util/git.js";
;(async () => ;(async () =>
{ {
@ -47,6 +48,7 @@ import { TMProfile } from "./mongo/profile.js";
} }
ApiModule.load(); // TODO: use fastify ApiModule.load(); // TODO: use fastify
GitUtil.getData();
if (process.env.NODE_ENV === "development") if (process.env.NODE_ENV === "development")
{ {

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

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

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,3 +1,20 @@
/*
* 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"; import wcmatch from "wildcard-match";
/* /*

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -18,36 +18,38 @@ import { execSync } from "child_process";
export class GitUtil export class GitUtil
{ {
private static cache: { lastUpdated: number, version?: string, commit?: string } = undefined!; private static cache: { version?: string, commit?: string } = undefined!;
public static getLatestVersion() private static getLatestVersion()
{ {
try return process.env.CURRENT_VERSION;
{ // try
const data = execSync("git describe --tags --exact-match").toString(); // {
return data.replace("\n", ""); // const data = execSync("git describe --tags --exact-match").toString();
} catch // return data.replace("\n", "");
{ // } catch
return undefined; // {
} // return undefined;
// }
} }
public static getLatestCommit() private static getLatestCommit()
{ {
try return process.env.CURRENT_COMMIT;
{ // try
const data = execSync("git rev-parse --short HEAD").toString(); // {
return data.replace("\n", ""); // const data = execSync("git rev-parse --short HEAD").toString();
} catch // return data.replace("\n", "");
{ // } catch
return undefined; // {
} // return undefined;
// }
} }
public static getData() public static getData()
{ {
if (this.cache && (this.cache.lastUpdated - Date.now()) < 30_000) if (this.cache)
{ {
return this.cache; return this.cache;
} }
@ -55,7 +57,6 @@ export class GitUtil
const data = { const data = {
version: this.getLatestVersion(), version: this.getLatestVersion(),
commit: this.getLatestCommit(), commit: this.getLatestCommit(),
lastUpdated: Date.now(),
}; };
this.cache = data; this.cache = data;

View File

@ -0,0 +1,42 @@
/*
* 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) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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. */ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ // "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. */ // "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/backend", /* Specify an output folder for all emitted files. */ "outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */ // "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */ // "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. */ // "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

@ -0,0 +1,17 @@
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

@ -0,0 +1,10 @@
<?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) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) ~ Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
~ ~
~ This program is free software: you can redistribute it and/or modify ~ 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 ~ it under the terms of the GNU Affero General Public License as published
@ -19,6 +19,7 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<link rel="icon" type="image/jpg" href="/favicon.png"/> <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"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>simrail.pro | Simrail Logs</title> <title>simrail.pro | Simrail Logs</title>

View File

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

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

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

View File

@ -0,0 +1,42 @@
<?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) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -21,8 +21,7 @@ import { Loader } from "./components/mini/loaders/PageLoader.tsx";
import { Home } from "./pages/Home"; import { Home } from "./pages/Home";
import DefaultLayout from "./layout/DefaultLayout"; import DefaultLayout from "./layout/DefaultLayout";
import "./i18n"; import "./i18n";
import { TrainLeaderboard } from "./pages/leaderboard/TrainLeaderboard.tsx"; import { Leaderboard } from "./pages/leaderboard/Leaderboard.tsx";
import { StationLeaderboard } from "./pages/leaderboard/StationsLeaderboard.tsx";
import { TrainLogs } from "./pages/logs/TrainLogs.tsx"; import { TrainLogs } from "./pages/logs/TrainLogs.tsx";
import { StationLogs } from "./pages/logs/StationLogs.tsx"; import { StationLogs } from "./pages/logs/StationLogs.tsx";
import { Profile } from "./pages/profiles/Profile.tsx"; import { Profile } from "./pages/profiles/Profile.tsx";
@ -83,12 +82,12 @@ function App()
} }
/> />
<Route <Route
path="/leaderboard/trains" path="/leaderboard/"
element={ element={
<> <>
<PageMeta title="simrail.pro | Train Leaderboard" <PageMeta title="simrail.pro | Train Leaderboard"
description="Simrail Stats - The best SimRail logs and statistics site!"/> description="Simrail Stats - The best SimRail logs and statistics site!"/>
<TrainLeaderboard/> <Leaderboard/>
</> </>
} }
/> />
@ -115,18 +114,6 @@ 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 <Route
path="/active/trains" path="/active/trains"
element={ element={

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,15 +0,0 @@
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) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -45,10 +45,10 @@ const DarkModeSwitcher = () =>
}` } }` }
> >
<span className="dark:hidden"> <span className="dark:hidden">
<LightIcon/> <LightIcon />
</span> </span>
<span className="hidden dark:inline-block"> <span className="hidden dark:inline-block">
<DarkIcon/> <DarkIcon />
</span> </span>
</span> </span>
</label> </label>

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -76,10 +76,13 @@ export const Header = (props: {
<div className="flex items-center gap-3 2xsm:gap-7"> <div className="flex items-center gap-3 2xsm:gap-7">
<ul className="flex items-center gap-2 2xsm:gap-4"> <ul className="flex items-center gap-2 2xsm:gap-4">
<a className="cursor-pointer" onClick={ () => i18n.changeLanguage("pl") }> <a className="cursor-pointer" onClick={ () => i18n.changeLanguage("pl") }>
<ReactCountryFlag countryCode={ "PL" } svg/> <ReactCountryFlag countryCode={ "PL" } svg alt={ "PL" }/>
</a> </a>
<a className="cursor-pointer" onClick={ () => i18n.changeLanguage("en") }> <a className="cursor-pointer" onClick={ () => i18n.changeLanguage("en") }>
<ReactCountryFlag countryCode={ "US" } svg/> <ReactCountryFlag countryCode={ "US" } svg alt={ "EN" }/>
</a>
<a className="cursor-pointer" onClick={ () => i18n.changeLanguage("cs") }>
<ReactCountryFlag countryCode={ "CZ" } svg alt={ "CZ" }/>
</a> </a>
</ul> </ul>
<ul className="flex items-center gap-2 2xsm:gap-4"> <ul className="flex items-center gap-2 2xsm:gap-4">

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -19,7 +19,6 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ErrorAlertIcon } from "../icons/AlertIcons.tsx"; import { ErrorAlertIcon } from "../icons/AlertIcons.tsx";
export const LoadError = () => export const LoadError = () =>
{ {
const { t } = useTranslation(); const { t } = useTranslation();

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

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

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -153,70 +153,17 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
</NavLink> </NavLink>
</li> </li>
<SidebarLinkGroup <li>
isOpen={ true } <NavLink
to="/leaderboard"
activeCondition={ 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") &&
pathname === "/leaderboard" || pathname.includes("leaderboard") "bg-graydark dark:bg-meta-4"
} }` }
> >
{ (handleClick, open) => <FaChartSimple/>
{ { t("sidebar.leaderboard") }
return ( </NavLink>
<React.Fragment> </li>
<NavLink
to="#"
className={ `group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${ (pathname === "/leaderboard" ||
(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 <SidebarLinkGroup
isOpen={ true } isOpen={ true }

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -0,0 +1,78 @@
/*
* 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) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -14,9 +14,60 @@
* See LICENSE for more. * See LICENSE for more.
*/ */
import { ChangeEventHandler } from "react"; import { ChangeEventHandler, Dispatch, ReactNode, SetStateAction } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FcInfo } from "react-icons/fc";
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>
</>;
};
export const Search = ({ searchItem, handleInputChange }: { export const Search = ({ searchItem, handleInputChange }: {
searchItem: string; searchItem: string;
@ -24,25 +75,48 @@ export const Search = ({ searchItem, handleInputChange }: {
}) => }) =>
{ {
const { t } = useTranslation(); const { t } = useTranslation();
return <> return <>
<div <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"> 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 justify-center items-center"> <div className="flex flex-col gap-6 xl:flex-row items-center justify-center">
<input <div className="w-full xl:w-1/2 grow">
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" <input onChange={ handleInputChange }
type="text" value={ searchItem }
onChange={ handleInputChange } type="text" placeholder={ t("search.placeholder") }
value={ searchItem } 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>
<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>
</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 }
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> </div>
</>; </>;
}; };

View File

@ -1,24 +0,0 @@
/*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* See LICENSE for more.
*/
import { 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) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -18,76 +18,51 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { TActiveStationPlayersData } from "../../../types/active.ts"; import { TActiveStationPlayersData } from "../../../types/active.ts";
import { UserIcons } from "../../mini/icons/UserIcons.tsx"; import { UserIcons } from "../../mini/icons/UserIcons.tsx";
import { toast } from "react-toastify";
export const ActiveStationTable = ({ stations }: { stations: TActiveStationPlayersData[] }) => export const ActiveStationTable = ({ stations }: { stations: TActiveStationPlayersData[] }) =>
{ {
const { t } = useTranslation(); 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 ( return (
<div <div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4">
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"> { stations.map((station) =>
<div className="flex flex-col"> {
<div className="grid grid-cols-3 rounded-sm bg-gray-2 dark:bg-meta-4 sm:grid-cols-4"> return <div
<div className="p-2.5 text-center xl:p-5"> key={ station.server + station.stationShort }
<h5 className="text-sm font-medium uppercase xsm:text-base"> className="flex flex-col align-center items-center rounded-sm border border-stroke bg-stroke shadow-default dark:border-strokedark dark:bg-boxdark">
{ t("active.server") } <div className="p-4">
</h5> <img className="rounded-full" src={ station.player.avatar } alt="Player"/>
</div> </div>
<div className="p-2.5 text-center xl:p-5"> <div className="flex flex-col p-2 align-center items-center">
<h5 className="text-sm font-medium uppercase xsm:text-base"> <h4 className="mb-3 text-xl font-semibold text-black dark:text-white">
{ t("active.user") } { station.player.username }
</h5> <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> </div>
<div className="p-2.5 text-center xl:p-5"> <div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap mt-auto mb-2">
<h5 className="text-sm font-medium uppercase xsm:text-base"> <Link to={ "/profile/" + (station.steam ?? station.player.id) }
{ t("active.station") } 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" : "" }` }
</h5> style={ station.player.flags.includes("private") ? { pointerEvents: "none" } : undefined }>{ t("log.buttons.profile") }</Link>
</div> <a
<div className="hidden p-2.5 text-center sm:block xl:p-5"> onClick={ () => report(station) }
<h5 className="text-sm font-medium uppercase xsm:text-base"> 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("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 }
> >
<div className="flex items-center justify-center p-2.5 lg:p-5"> { t("log.buttons.report") }
<p className="text-meta-6">{ station.server.toUpperCase() }</p> </a>
</div> </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> </div>
); );
}; };

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -18,6 +18,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { TActiveTrainPlayersData } from "../../../types/active.ts"; import { TActiveTrainPlayersData } from "../../../types/active.ts";
import { UserIcons } from "../../mini/icons/UserIcons.tsx"; import { UserIcons } from "../../mini/icons/UserIcons.tsx";
import { toast } from "react-toastify";
export const ActiveTrainTable = ({ trains }: { export const ActiveTrainTable = ({ trains }: {
trains: TActiveTrainPlayersData[], trains: TActiveTrainPlayersData[],
@ -25,74 +26,48 @@ export const ActiveTrainTable = ({ trains }: {
{ {
const { t } = useTranslation(); 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 ( return (
<div <div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4">
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"> { trains.map((train) =>
<div className="flex flex-col"> {
<div className="grid grid-cols-3 rounded-sm bg-gray-2 dark:bg-meta-4 sm:grid-cols-4"> return <div
<div className="p-2.5 text-center xl:p-5"> key={ train.server + train.trainNumber }
<h5 className="text-sm font-medium uppercase xsm:text-base"> className="flex flex-col align-center items-center rounded-sm border border-stroke bg-stroke shadow-default dark:border-strokedark dark:bg-boxdark">
{ t("active.server") } <div className="p-4">
</h5> <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> </div>
<div className="p-2.5 text-center xl:p-5"> <div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap mt-auto mb-2">
<h5 className="text-sm font-medium uppercase xsm:text-base"> <Link to={ "/profile/" + (train.steam ?? train.player.id) }
{ t("active.user") } 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" : "" }` }
</h5> style={ train.player.flags.includes("private") ? { pointerEvents: "none" } : undefined }>{ t("log.buttons.profile") }</Link>
</div> <a
onClick={ () => report(train) }
<div className="p-2.5 text-center xl:p-5"> 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"
<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 }
> >
<div className="flex items-center justify-center p-2.5 lg:p-5"> { t("log.buttons.report") }
<p className="text-meta-6">{ train.server.toUpperCase() }</p> </a>
</div> </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> </div>
) );
;
}; };

View File

@ -1,87 +0,0 @@
/*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* See LICENSE for more.
*/
import { 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

@ -0,0 +1,75 @@
/*
* 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

@ -1,114 +0,0 @@
/*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* See LICENSE for more.
*/
import { 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

@ -1,87 +0,0 @@
/*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* See LICENSE for more.
*/
import { 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

@ -1,115 +0,0 @@
/*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* See LICENSE for more.
*/
import { 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) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -44,9 +44,11 @@ export const StationLog = ({ data }: { data: TLogStationData }) =>
className="overflow-hidden rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark"> 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="px-4 pt-6 text-center lg:pb-8 xl:pb-11.5">
<div <div
className="mx-auto w-full max-w-30 rounded-full bg-white/20 p-1 backdrop-blur sm:h-44 sm:max-w-44 sm:p-3"> className="mx-auto max-w-44 rounded-full">
<div className="relative drop-shadow-2"> <div className="relative">
<img className="rounded-full" src={ data.player.avatar } alt="profile"/> <img className="rounded-full"
src={ data.player.avatar }
alt="Player"/>
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -44,9 +44,11 @@ export const TrainLog = ({ data }: { data: TLogTrainData }) =>
className="overflow-hidden rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark"> 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="px-4 pt-6 text-center lg:pb-8 xl:pb-11.5">
<div <div
className="mx-auto w-full max-w-30 rounded-full bg-white/20 p-1 backdrop-blur sm:h-44 sm:max-w-44 sm:p-3"> className="mx-auto max-w-44 rounded-full">
<div className="relative drop-shadow-2"> <div className="relative">
<img className="rounded-full" src={ data.player.avatar } alt="profile"/> <img className="rounded-full"
src={ data.player.avatar }
alt="Player"/>
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -19,6 +19,8 @@ import { Link } from "react-router-dom";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { TStationRecord } from "../../../types/station.ts"; import { TStationRecord } from "../../../types/station.ts";
import { UserIcons } from "../../mini/icons/UserIcons.tsx"; import { UserIcons } from "../../mini/icons/UserIcons.tsx";
import { toast } from "react-toastify";
// setSearchItem: Dispatch<SetStateAction<string>> // setSearchItem: Dispatch<SetStateAction<string>>
export const StationTable = ({ stations }: { export const StationTable = ({ stations }: {
@ -27,76 +29,59 @@ export const StationTable = ({ stations }: {
{ {
const { t } = useTranslation(); 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 ( 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>
{ stations.map((station, key) => ( <div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4">
<div { stations.map((station) =>
className={ `grid grid-cols-3 sm:grid-cols-4 ${ stations.length === (key + 1) {
? "" return <div
: "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">
key={ station.id } <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"
> >
<div className="flex items-center justify-center gap-3 p-2.5 lg:p-5"> { t("log.buttons.report") }
<p className="text-black dark:text-white sm:block break-all"> </a>
<Link to={ "/profile/" + (station.steam ?? station.player.id) } </div>
className="color-orchid">{ station.username ?? station.player.username }</Link> </div>;
<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) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -19,6 +19,7 @@ import { Link } from "react-router-dom";
import { TTrainRecord } from "../../../types/train.ts"; import { TTrainRecord } from "../../../types/train.ts";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { UserIcons } from "../../mini/icons/UserIcons.tsx"; import { UserIcons } from "../../mini/icons/UserIcons.tsx";
import { toast } from "react-toastify";
// setSearchItem: Dispatch<SetStateAction<string>> // setSearchItem: Dispatch<SetStateAction<string>>
export const TrainTable = ({ trains }: { export const TrainTable = ({ trains }: {
@ -27,95 +28,61 @@ export const TrainTable = ({ trains }: {
{ {
const { t } = useTranslation(); 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 ( return (
<div <div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4">
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"> { trains.map((train) =>
<div className="flex flex-col"> {
<div className="grid grid-cols-3 rounded-sm bg-gray-2 dark:bg-meta-4 sm:grid-cols-6"> return <div
<div className="p-2.5 text-center xl:p-5"> key={ train.id }
<h5 className="text-sm font-medium uppercase xsm:text-base"> className="flex flex-col align-center items-center rounded-sm border border-stroke bg-stroke shadow-default dark:border-strokedark dark:bg-boxdark">
{ t("logs.user") } <div className="p-4">
</h5> <img className="rounded-full"
src={ train.player.avatar }
alt="Player"/>
</div> </div>
<div className="p-2.5 text-center xl:p-5"> <div className="flex flex-col p-2 align-center items-center">
<h5 className="text-sm font-medium uppercase xsm:text-base"> <h4 className="mb-3 text-xl font-semibold text-black dark:text-white">
{ t("logs.train") } { train.player.username }
</h5> <UserIcons flags={ train.player.flags }/>
</div> </h4>
<div className="hidden sm:block p-2.5 text-center xl:p-5"> <p>{ t("log.train.train", { name: train.trainName, number: train.trainNumber }) }</p>
<h5 className="text-sm font-medium uppercase xsm:text-base"> <p>{ t("log.train.server", { server: train.server.toUpperCase() }) }</p>
{ t("logs.points") } { train.joinedDate &&
</h5> <p>{ t("log.train.joined", { date: dayjs(train.joinedDate).format("HH:mm DD/MM/YYYY") }) }</p> }
</div> <p>{ t("log.train.left", { date: dayjs(train.leftDate).format("HH:mm DD/MM/YYYY") }) }</p>
<div className="hidden sm:block p-2.5 text-center xl:p-5"> { train.joinedDate &&
<h5 className="text-sm font-medium uppercase xsm:text-base"> <p>{ t("log.train.spent", { date: dayjs.duration(train.leftDate - train.joinedDate).format("H[h] m[m]") }) }</p> }
{ 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) => ( <p>{ t("log.train.distance", { distance: train.distance ? (train.distance / 1000).toFixed(2) : "--" }) }</p>
<div
className={ `grid grid-cols-3 sm:grid-cols-6 ${ trains.length === (key + 1) <p>{ t("log.train.points", { points: train.points || "--" }) }</p>
? "" </div>
: "border-b border-stroke dark:border-strokedark"
}` } <div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap mt-auto mb-2">
key={ train.id } <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") }
<div className="flex items-center justify-center gap-3 p-2.5 lg:p-5"> </a>
<p className="text-black dark:text-white sm:block break-all"> </div>
<Link to={ "/profile/" + (train.steam ?? train.player.id) } </div>;
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) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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"> 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="px-4 pt-6 text-center lg:pb-8 xl:pb-11.5">
<div <div
className="mx-auto w-full max-w-30 rounded-full p-1 sm:h-44 sm:max-w-44"> className="mx-auto max-w-44 rounded-full">
<div className="relative rounded-full"> <div className="relative rounded-full">
<img className="rounded-full" src={ data.player.avatar } alt="profile"/> <img className="rounded-full" src={ data.player.avatar } alt="profile"/>
{ data.active && { data.active &&

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -18,60 +18,36 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { UserIcons } from "../../mini/icons/UserIcons.tsx"; import { UserIcons } from "../../mini/icons/UserIcons.tsx";
import { TProfilePlayer } from "../../../types/profile.ts"; import { TProfilePlayer } from "../../../types/profile.ts";
// import { formatTime } from "../../../util/time.ts";
export const ProfilesTable = ({ profiles }: { profiles: TProfilePlayer[] }) => export const ProfilesTable = ({ profiles }: { profiles: TProfilePlayer[] }) =>
{ {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div <div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4">
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"> { profiles.map((player) =>
{
<div className="flex flex-col"> return <div
<div className="grid grid-cols-2 rounded-sm bg-gray-2 dark:bg-meta-4"> 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-2.5 text-center xl:p-5"> <div className="p-4">
<h5 className="text-sm font-medium uppercase xsm:text-base"> <img className="rounded-full" src={ player.avatar } alt="Player"/>
{ t("profiles.user") }
</h5>
</div> </div>
<div className="hidden p-2.5 text-center sm:block xl:p-5"> <div className="flex flex-col p-2 align-center items-center">
<h5 className="text-sm font-medium uppercase xsm:text-base"> <h4 className="mb-3 text-xl font-semibold text-black dark:text-white">
{ t("profiles.actions") } { player.username }
</h5> <UserIcons flags={ player.flags }/>
</h4>
</div> </div>
</div>
{ profiles.map((profile, key) => ( <div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap sm:flex-nowrap mt-auto mb-2">
<div <Link to={ "/profile/" + player.id }
className={ `grid grid-cols-2 ${ profiles.length === (key + 1) // todo: ... 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>
: "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>;
}) }
<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> </div>
); );
}; };

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -255,20 +255,6 @@ span.flatpickr-weekday,
@apply bg-primary border-primary dark:border-primary; @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] { [x-cloak] {
display: none !important; display: none !important;
} }

View File

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

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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; return item ? JSON.parse(item) : initialValue;
} catch } catch
{ {
return item ? item : initialValue; return (item && item !== "undefined") ? item : initialValue;
} }
} }
} catch (error) } catch (error)

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -19,6 +19,7 @@ import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector"; import LanguageDetector from "i18next-browser-languagedetector";
import translationsInEng from "./languages/en.json"; import translationsInEng from "./languages/en.json";
import translationsInPl from "./languages/pl.json"; import translationsInPl from "./languages/pl.json";
import translationsInCs from "./languages/cs.json";
const resources = { const resources = {
en: { en: {
@ -27,6 +28,9 @@ const resources = {
pl: { pl: {
translation: translationsInPl, translation: translationsInPl,
}, },
cs: {
translation: translationsInCs,
}
}; };
void i18n void i18n
@ -35,10 +39,11 @@ void i18n
.init({ .init({
resources, resources,
debug: false, debug: false,
fallbackLng: { fallbackLng: (code: string) => {
"pl-PL": [ "pl" ], if (code.includes('pl')) return 'pl'; // Polish
if (resources as any['cs'] && code.includes('cs')) return 'cs'; // Czech
default: [ "en" ], return 'en'; // English
}, },
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,

View File

@ -0,0 +1,173 @@
{
"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,17 +1,8 @@
{ {
"_": {
"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": { "search": {
"placeholder": "Type to search.", "placeholder": "ex. alekswilc",
"tip": "You can search for multiple data using a comma, ex. pl2,alekswilc (server name, username)" "placeholder_with_station": "ex. alekswilc,Katowice",
"none": "None"
}, },
"home": { "home": {
"stats": { "stats": {
@ -23,7 +14,8 @@
"description": "Simrail Stats - The best SimRail logs and statistics site!", "description": "Simrail Stats - The best SimRail logs and statistics site!",
"buttons": { "buttons": {
"project": "Project page", "project": "Project page",
"forum": "Forum Page" "forum": "Forum page",
"discord": "Discord server"
}, },
"footer": { "footer": {
"license": "License:", "license": "License:",
@ -37,37 +29,19 @@
"description": "It seems you're lost.", "description": "It seems you're lost.",
"button": "Return to homepage." "button": "Return to homepage."
}, },
"profiles": {
"user": "Player",
"profile": "Profile",
"actions": "Actions"
},
"leaderboard": { "leaderboard": {
"user": "Player", "train": {
"time": "Time", "distance": "Driven distance: {{distance}}km",
"distance": "Distance", "points": "Earned points: {{points}}"
"points": "Points", },
"profile": "Profile", "station": {
"actions": "Actions" "time": "Time spent as a dispatcher: {{time}}"
}, },
"active": { "buttons": {
"server": "Server", "trainDistance": "Longest distance traveled",
"user": "Player", "trainPoints": "Most points",
"train": "Train", "dispatcherTime": "Most hours as dispatcher"
"station": "Station", }
"profile": "Profile",
"actions": "Actions"
},
"logs": {
"user": "Player",
"time": "Time",
"distance": "Distance",
"points": "Points",
"profile": "Profile",
"record": "Record",
"train": "Train",
"actions": "Actions",
"station": "Station"
}, },
"content_loader": { "content_loader": {
"error": { "error": {
@ -145,12 +119,13 @@
}, },
"toasts": { "toasts": {
"copied": "Link copied to clipboard!", "copied": "Link copied to clipboard!",
"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!" "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!"
}, },
"buttons": { "buttons": {
"report": "Report", "report": "Report",
"copy": "Copy link", "copy": "Copy link",
"profile": "Profile" "profile": "Profile",
"record": "Record"
} }
}, },
"sidebar": { "sidebar": {
@ -164,7 +139,7 @@
"admin": "ADMIN", "admin": "ADMIN",
"logged": "Logged as {{username}}", "logged": "Logged as {{username}}",
"logout": "Log out", "logout": "Log out",
"profiles": "Profiles" "profiles": "Players"
}, },
"icons": { "icons": {
"admin": "Administrator", "admin": "Administrator",

View File

@ -1,17 +1,8 @@
{ {
"_": {
"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": { "search": {
"placeholder": "Wpisz, aby wyszukać", "placeholder": "np. alekswilc",
"tip": "Możesz wyszukiwać wiele danych używając przecinka, np pl2,alekswilc (nazwa serwera, nazwa użytkownika)" "placeholder_with_station": "np. alekswilc,Katowice",
"none": "Brak"
}, },
"home": { "home": {
"stats": { "stats": {
@ -23,7 +14,8 @@
"description": "Najbardziej rozbudowana strona z logami i statystykami gry SimRail!", "description": "Najbardziej rozbudowana strona z logami i statystykami gry SimRail!",
"buttons": { "buttons": {
"project": "Strona projektu", "project": "Strona projektu",
"forum": "Strona na forum" "forum": "Strona na forum",
"discord": "Serwer discord"
}, },
"footer": { "footer": {
"license": "Licencja:", "license": "Licencja:",
@ -38,36 +30,18 @@
"button": "Wróć do strony głównej" "button": "Wróć do strony głównej"
}, },
"leaderboard": { "leaderboard": {
"user": "Gracz", "train": {
"time": "Czas", "distance": "Przejechany dystans: {{distance}}km",
"distance": "Dystans", "points": "Zdobyte punkty: {{points}}"
"points": "Punkty", },
"profile": "Profil", "station": {
"actions": "Akcje" "time": "Spędzony czas jako dyżurny ruchu: {{time}}"
}, },
"profiles": { "buttons": {
"user": "Gracz", "trainDistance": "Największy przejechany dystans",
"profile": "Profil", "trainPoints": "Najwięcej punktów",
"actions": "Akcje" "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": { "content_loader": {
"error": { "error": {
@ -145,19 +119,15 @@
}, },
"toasts": { "toasts": {
"copied": "Skopiowano link do schowka!", "copied": "Skopiowano link do schowka!",
"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!" "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!"
}, },
"buttons": { "buttons": {
"report": "Zgłoś", "report": "Zgłoś",
"copy": "Kopiuj link", "copy": "Kopiuj link",
"profile": "Profil" "profile": "Profil",
"record": "Rekord"
} }
}, },
"icons": {
"admin": "Administrator",
"leaderboard_hidden": "Tablica wyników ukryta",
"hidden": "Profil ukryty"
},
"sidebar": { "sidebar": {
"home": "Strona główna", "home": "Strona główna",
"logs": "Logi", "logs": "Logi",
@ -169,7 +139,12 @@
"admin": "ADMIN", "admin": "ADMIN",
"logged": "Zalogowano jako {{username}}", "logged": "Zalogowano jako {{username}}",
"logout": "Wyloguj", "logout": "Wyloguj",
"profiles": "Profile" "profiles": "Gracze"
},
"icons": {
"admin": "Administrator",
"leaderboard_hidden": "Tablica wyników ukryta",
"hidden": "Profil ukryty"
}, },
"admin": { "admin": {
"header": "Akcje moderacyjne", "header": "Akcje moderacyjne",

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -21,12 +21,10 @@ import App from "./App";
import "./css/style.css"; import "./css/style.css";
import "./css/satoshi.css"; import "./css/satoshi.css";
import "flatpickr/dist/flatpickr.min.css"; import "flatpickr/dist/flatpickr.min.css";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime.js"; import relativeTime from "dayjs/plugin/relativeTime.js";
import duration from "dayjs/plugin/duration.js"; import duration from "dayjs/plugin/duration.js";
dayjs.extend(duration); dayjs.extend(duration);
dayjs.extend(relativeTime); dayjs.extend(relativeTime);

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -17,7 +17,7 @@
import { ChangeEvent, useEffect, useState } from "react"; import { ChangeEvent, useEffect, useState } from "react";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { Search } from "../../components/mini/util/Search.tsx"; import { SearchWithServerSelector } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { get } from "../../util/fetcher.ts"; import { get } from "../../util/fetcher.ts";
import useSWR from "swr"; import useSWR from "swr";
@ -33,51 +33,55 @@ export const ActiveStationsPlayers = () =>
const { data, error, isLoading } = useSWR(`/active/station/?${ params.toString() }`, get, { refreshInterval: 10_000, errorRetryCount: 5 }); const { data, error, isLoading } = useSWR(`/active/station/?${ params.toString() }`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchParams, setSearchParams ] = useSearchParams(); const [ searchParams, setSearchParams ] = useSearchParams();
const [ searchItem, setSearchItem ] = useState(searchParams.get("q") ?? ""); const [ searchItem, setSearchItem ] = useState(searchParams.get("query") ?? "");
const [ server, setServer ] = useState(searchParams.get("server") ?? "");
const [ searchValue ] = useDebounce(searchItem, 500); const [ searchValue ] = useDebounce(searchItem, 500);
useEffect(() => useEffect(() =>
{ {
searchValue === "" ? searchParams.delete("q") : searchParams.set("q", searchValue);
const params = new URLSearchParams(); const params = new URLSearchParams();
searchValue && params.set("q", searchValue); searchValue && params.set("query", searchValue);
server && params.set("server", server);
setSearchParams(params.toString()); setSearchParams(params.toString());
setParams(params); setParams(params);
}, [ searchValue ]); }, [ searchValue, server ]);
useEffect(() => useEffect(() =>
{ {
setSearchItem(searchParams.get("q") ?? ""); setSearchItem(searchParams.get("query") ?? "");
}, [ searchParams ]); setServer(searchParams.get("server") ?? "");
}, []);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => const handleInputChange = (e: ChangeEvent<HTMLInputElement>) =>
{ {
setSearchItem(e.target.value); setSearchItem(e.target.value);
}; };
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-10">
<Search handleInputChange={ handleInputChange } searchItem={ searchItem }/> <SearchWithServerSelector handleInputChange={ handleInputChange } searchItem={ searchItem }
<> servers={ data?.code === 200 ? data?.data?.servers : [] }
{ error && <LoadError/> } server={ server } setServer={ setServer }/>
{ error && <LoadError/> }
{ isLoading && <ContentLoader/> } { isLoading && <ContentLoader/> }
{ data && (data && data.code === 404) || (data && !data?.data?.records?.length) && { data && (data && data.code === 404) || (data && !data?.data?.records?.length) &&
<WarningAlert title={ t("content_loader.notfound.header") } <WarningAlert title={ t("content_loader.notfound.header") }
description={ t("content_loader.notfound.description") }/> } description={ t("content_loader.notfound.description") }/> }
{ data && data.code === 200 && data.data && !!data?.data?.records?.length &&
<ActiveStationTable stations={ data.data.records }/> }
{ data && data.code === 200 && data.data && !!data?.data?.records?.length &&
<ActiveStationTable stations={ data.data.records }/> }
</>
</div> </div>
</> </>
); )
;
}; };

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -17,7 +17,7 @@
import { ChangeEvent, useEffect, useState } from "react"; import { ChangeEvent, useEffect, useState } from "react";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { Search } from "../../components/mini/util/Search.tsx"; import { SearchWithServerSelector } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { get } from "../../util/fetcher.ts"; 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 { data, error, isLoading } = useSWR(`/active/train/?${ params.toString() }`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchParams, setSearchParams ] = useSearchParams(); const [ searchParams, setSearchParams ] = useSearchParams();
const [ searchItem, setSearchItem ] = useState(searchParams.get("q") ?? ""); const [ searchItem, setSearchItem ] = useState(searchParams.get("query") ?? "");
const [ server, setServer ] = useState(searchParams.get("server") ?? "");
const [ searchValue ] = useDebounce(searchItem, 500); const [ searchValue ] = useDebounce(searchItem, 500);
useEffect(() => useEffect(() =>
{ {
searchValue === "" ? searchParams.delete("q") : searchParams.set("q", searchValue);
const params = new URLSearchParams(); const params = new URLSearchParams();
searchValue && params.set("q", searchValue); searchValue && params.set("query", searchValue);
server && params.set("server", server);
setSearchParams(params.toString()); setSearchParams(params.toString());
setParams(params); setParams(params);
}, [ searchValue ]); }, [ searchValue, server ]);
useEffect(() => useEffect(() =>
{ {
setSearchItem(searchParams.get("q") ?? ""); setSearchItem(searchParams.get("query") ?? "");
}, [ searchParams ]); setServer(searchParams.get("server") ?? "");
}, []);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => const handleInputChange = (e: ChangeEvent<HTMLInputElement>) =>
{ {
@ -64,7 +64,9 @@ export const ActiveTrainPlayers = () =>
return ( return (
<> <>
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-10">
<Search handleInputChange={ handleInputChange } searchItem={ searchItem }/> <SearchWithServerSelector handleInputChange={ handleInputChange } searchItem={ searchItem }
servers={ data?.code === 200 ? data?.data?.servers : [] }
server={ server } setServer={ setServer }/>
<> <>
{ error && <LoadError/> } { error && <LoadError/> }

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -0,0 +1,127 @@
/*
* 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

@ -1,84 +0,0 @@
/*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* See LICENSE for more.
*/
import { 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

@ -1,88 +0,0 @@
/*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* See LICENSE for more.
*/
import { 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) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev) * Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -17,13 +17,14 @@
import { ChangeEvent, useEffect, useState } from "react"; import { ChangeEvent, useEffect, useState } from "react";
import { StationTable } from "../../components/pages/logs/StationTable.tsx"; import { StationTable } from "../../components/pages/logs/StationTable.tsx";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { Search } from "../../components/mini/util/Search.tsx"; import { SearchWithServerSelector } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import useSWR from "swr"; import useSWR from "swr";
import { get } from "../../util/fetcher.ts"; import { get } from "../../util/fetcher.ts";
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx"; import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx"; import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Paginator } from "../../components/mini/util/Paginator.tsx";
export const StationLogs = () => export const StationLogs = () =>
{ {
@ -31,25 +32,28 @@ export const StationLogs = () =>
const { data, error, isLoading } = useSWR(`/stations/?${ params.toString() }`, get, { refreshInterval: 10_000, errorRetryCount: 5 }); const { data, error, isLoading } = useSWR(`/stations/?${ params.toString() }`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchParams, setSearchParams ] = useSearchParams(); const [ searchParams, setSearchParams ] = useSearchParams();
const [ searchItem, setSearchItem ] = useState(searchParams.get("q") ?? ""); 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 [ searchValue ] = useDebounce(searchItem, 500); const [ searchValue ] = useDebounce(searchItem, 500);
useEffect(() => useEffect(() =>
{ {
searchValue === "" ? searchParams.delete("q") : searchParams.set("q", searchValue);
const params = new URLSearchParams(); const params = new URLSearchParams();
searchValue && params.set("q", searchValue); searchValue && params.set("query", searchValue);
server && params.set("server", server);
page && params.set("page", page.toString());
setSearchParams(params.toString()); setSearchParams(params.toString());
setParams(params); setParams(params);
}, [ searchValue ]); }, [ searchValue, server, page ]);
useEffect(() => useEffect(() =>
{ {
setSearchItem(searchParams.get("q") ?? ""); setSearchItem(searchParams.get("query") ?? "");
}, [ searchParams ]); setServer(searchParams.get("server") ?? "");
setPage(parseInt(searchParams.get("page") as string) || 1);
}, []);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => const handleInputChange = (e: ChangeEvent<HTMLInputElement>) =>
{ {
@ -61,7 +65,10 @@ export const StationLogs = () =>
return ( return (
<> <>
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-10">
<Search handleInputChange={ handleInputChange } searchItem={ searchItem }/> <SearchWithServerSelector handleInputChange={ handleInputChange } searchItem={ searchItem }
servers={ data?.code === 200 ? data?.data?.servers : [] }
server={ server } setServer={ setServer }
/>
<> <>
{ error && <LoadError/> } { error && <LoadError/> }
@ -72,7 +79,11 @@ export const StationLogs = () =>
description={ t("content_loader.notfound.description") }/> } description={ t("content_loader.notfound.description") }/> }
{ data && data.code === 200 && !!data?.data?.records?.length && { data && data.code === 200 && !!data?.data?.records?.length &&
<StationTable stations={ data.data.records }/> } <>
<StationTable stations={ data.data.records }/>
<Paginator page={ page } pages={ data.data.pages } setPage={ setPage }/>
</>
}
</> </>
</div> </div>
</> </>

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