Compare commits

...

91 Commits
v3.0.4 ... main

Author SHA1 Message Date
996f16a313
Merge pull request 'update license year.' (#119) from license-year into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #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
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #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
All checks were successful
Build simrail.pro / build (push) Successful in 16s
continuous-integration/drone/push Build is passing
Reviewed-on: #117
2025-03-27 16:54:39 +01:00
96c17ffe85
build(): drone CI
All checks were successful
Build simrail.pro / build (push) Successful in 14s
2025-03-27 16:54:21 +01:00
8db158afe9
Merge pull request 'build(): drone CI' (#116) from drone-ci into main
Some checks failed
continuous-integration/drone/push Build is failing
Build simrail.pro / build (push) Successful in 14s
Reviewed-on: #116
2025-03-27 16:51:58 +01:00
6015349c6f
build(): drone CI
Some checks failed
Build simrail.pro / build (push) Has been cancelled
2025-03-27 16:51:18 +01:00
cd191a6f61
Merge pull request 'build(): drone CI' (#115) from drone-ci into main
Some checks failed
continuous-integration/drone/push Build is failing
Build simrail.pro / build (push) Successful in 14s
Reviewed-on: #115
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2025-03-27 16:50:37 +01:00
08bfe767bb
build(): drone CI
All checks were successful
Build simrail.pro / build (push) Successful in 41s
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
All checks were successful
Build simrail.pro / build (push) Successful in 29s
Reviewed-on: #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
All checks were successful
Build simrail.pro / build (push) Successful in 1m6s
2025-03-05 14:37:07 +01:00
623bdd8d42
Merge pull request 'fix(): rollbacl' (#113) from feat-1 into main
All checks were successful
Build simrail.pro / build (push) Successful in 23s
Reviewed-on: #113
2025-03-04 16:02:11 +01:00
51b3ff2e22
fix(): rollbacl
All checks were successful
Build simrail.pro / build (push) Successful in 42s
2025-03-04 16:01:56 +01:00
816ee5c454
Merge pull request 'fix(): rollbacl' (#112) from feat-1 into main
Some checks failed
Build simrail.pro / build (push) Has been cancelled
Reviewed-on: #112
2025-03-04 16:01:23 +01:00
9a3f004c72
fix(): rollbacl
Some checks failed
Build simrail.pro / build (push) Failing after 32s
2025-03-04 16:01:07 +01:00
f528da171b
Merge pull request 'fix(): rollbacl' (#111) from feat-1 into main
All checks were successful
Build simrail.pro / build (push) Successful in 25s
Reviewed-on: #111
2025-03-04 16:00:00 +01:00
b23921a28c
fix(): rollbacl
All checks were successful
Build simrail.pro / build (push) Successful in 54s
2025-03-04 15:59:42 +01:00
e045ded046
Merge pull request 'fix(): rollbacl' (#110) from feat-1 into main
Some checks failed
Build simrail.pro / build (push) Failing after 23s
Reviewed-on: #110
2025-03-04 15:59:19 +01:00
859b7ab3cc
fix(): rollbacl
Some checks failed
Build simrail.pro / build (push) Failing after 26s
2025-03-04 15:58:58 +01:00
34432e9622
Merge pull request 'fix(): rollbacl' (#109) from feat-1 into main
All checks were successful
Build simrail.pro / build (push) Successful in 23s
Reviewed-on: #109
2025-03-04 15:54:47 +01:00
3af371703b
fix(): rollbacl
All checks were successful
Build simrail.pro / build (push) Successful in 43s
2025-03-04 15:54:28 +01:00
fcef4e428e
Merge pull request 'fix(): rollbacl' (#108) from feat-1 into main
All checks were successful
Build simrail.pro / build (push) Successful in 22s
Reviewed-on: #108
2025-03-04 15:49:46 +01:00
ca8e270c5e
fix(): rollbacl
All checks were successful
Build simrail.pro / build (push) Successful in 43s
2025-03-04 15:49:30 +01:00
a9d35c604e
Merge pull request 'fix(): rollbacl' (#107) from feat-1 into main
Some checks failed
Build simrail.pro / build (push) Failing after 23s
Reviewed-on: #107
2025-03-04 15:48:40 +01:00
8c24a29de2
fix(): rollbacl
Some checks failed
Build simrail.pro / build (push) Failing after 43s
2025-03-04 15:48:17 +01:00
e079a1177e
Merge pull request 'fix(): fix nginx' (#106) from feat-1 into main
Some checks failed
Build simrail.pro / build (push) Failing after 23s
Reviewed-on: #106
2025-03-04 15:46:47 +01:00
7bf053e452
fix(): fix nginx
Some checks failed
Build simrail.pro / build (push) Failing after 23s
2025-03-04 15:46:09 +01:00
328f665c5c
Merge pull request 'fix(): fix nginx' (#105) from feat-1 into main
Some checks failed
Build simrail.pro / build (push) Failing after 23s
Reviewed-on: #105
2025-03-04 15:45:21 +01:00
05fe82eff1
fix(): fix nginx
Some checks failed
Build simrail.pro / build (push) Failing after 33s
2025-03-04 15:45:05 +01:00
3143ce1058
Merge pull request 'fix(): fix nginx' (#104) from feat-1 into main
Some checks failed
Build simrail.pro / build (push) Failing after 23s
Reviewed-on: #104
2025-03-04 15:44:06 +01:00
85d18190a7
fix(): fix nginx
Some checks failed
Build simrail.pro / build (push) Failing after 44s
2025-03-04 15:43:51 +01:00
45434f5a2d
Merge pull request 'fix(): fix nginx' (#103) from feat-1 into main
All checks were successful
Build simrail.pro / build (push) Successful in 23s
Reviewed-on: #103
2025-03-04 15:41:16 +01:00
922b7ea633
fix(): fix nginx
All checks were successful
Build simrail.pro / build (push) Successful in 39s
2025-03-04 15:40:57 +01:00
f0f01ccda1
Merge pull request 'fix(): fix nginx' (#102) from feat-1 into main
All checks were successful
Build simrail.pro / build (push) Successful in 23s
Reviewed-on: #102
2025-03-04 15:38:43 +01:00
947bd5dedc
fix(): fix nginx
All checks were successful
Build simrail.pro / build (push) Successful in 56s
2025-03-04 15:38:21 +01:00
a2a9fd25b5
Merge pull request 'fix(): fix nginx' (#101) from feat-1 into main
All checks were successful
Build simrail.pro / build (push) Successful in 23s
Reviewed-on: #101
2025-03-04 15:36:10 +01:00
afdc700a64
fix(): fix nginx
All checks were successful
Build simrail.pro / build (push) Successful in 39s
2025-03-04 15:35:54 +01:00
f5ba173b24
Merge pull request 'fix(): fix nginx' (#100) from feat-1 into main
All checks were successful
Build simrail.pro / build (push) Successful in 22s
Reviewed-on: #100
2025-03-04 15:34:53 +01:00
7459649829
fix(): fix nginx
All checks were successful
Build simrail.pro / build (push) Successful in 39s
2025-03-04 15:34:37 +01:00
31c1f3fc40
Merge pull request 'fix(): fix nginx' (#99) from feat-1 into main
All checks were successful
Build simrail.pro / build (push) Successful in 22s
Reviewed-on: #99
2025-03-04 15:31:35 +01:00
df1d9df212
fix(): fix nginx
All checks were successful
Build simrail.pro / build (push) Successful in 44s
2025-03-04 15:31:03 +01:00
680a18d15a
Merge pull request 'fix(): fix nginx' (#98) from feat-1 into main
Some checks failed
Build simrail.pro / build (push) Failing after 22s
Reviewed-on: #98
2025-03-04 15:30:06 +01:00
40f31ba723
fix(): fix nginx
Some checks failed
Build simrail.pro / build (push) Failing after 31s
2025-03-04 15:29:41 +01:00
f9974a3430
Merge pull request 'fix(): rollbacl' (#97) from feat-1 into main
All checks were successful
Build simrail.pro / build (push) Successful in 23s
Reviewed-on: #97
2025-03-04 15:20:36 +01:00
c5249da57e
fix(): rollbacl
All checks were successful
Build simrail.pro / build (push) Successful in 34s
2025-03-04 15:20:19 +01:00
85e00e8d52
Merge pull request 'fix(): fix nginx' (#96) from feat-1 into main
All checks were successful
Build simrail.pro / build (push) Successful in 23s
Reviewed-on: #96
2025-03-04 15:16:27 +01:00
5841c77913
fix(): fix nginx
All checks were successful
Build simrail.pro / build (push) Successful in 35s
2025-03-04 15:16:03 +01:00
84d2972869
Merge pull request 'fix(): nginx config for spa' (#95) from feat-1 into main
All checks were successful
Build simrail.pro / build (push) Successful in 22s
Reviewed-on: #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
All checks were successful
Build simrail.pro / build (push) Successful in 37s
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
All checks were successful
Build simrail.pro / build (push) Successful in 25s
Reviewed-on: #93
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2025-02-20 01:39:15 +01:00
3db6ced4d2
feat(): docker, fix darkmode
All checks were successful
Build simrail.pro / build (push) Successful in 1m29s
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
All checks were successful
Build simrail.pro / build (push) Successful in 20s
Reviewed-on: #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
All checks were successful
Build simrail.pro / build (push) Successful in 1m2s
2025-02-11 03:14:44 +01:00
7617738ab2
Merge pull request 'fix(): active trains search' (#90) from v3.0.1 into main
All checks were successful
Build simrail.pro / build (push) Successful in 18s
Reviewed-on: #90
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2025-01-13 02:19:08 +01:00
9c3d3e0767
fix(): fix workflows
All checks were successful
Build simrail.pro / build (push) Successful in 18s
2025-01-13 02:18:21 +01:00
294068ee97
fix(): fix workflows
All checks were successful
Build simrail.pro / build (push) Successful in 1m6s
Build simrail.pro / build (pull_request) Successful in 19s
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
All checks were successful
Build simrail.pro / build (push) Successful in 19s
Reviewed-on: #89
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2024-12-31 02:29:47 +01:00
77a9540be4
Update .gitea/workflows/build.yaml
All checks were successful
Build simrail.pro / build (push) Successful in 18s
2024-12-31 02:29:04 +01:00
f8f5a38add
Update .gitea/workflows/build.yaml
All checks were successful
Build simrail.pro / build (push) Successful in 18s
2024-12-31 02:26:37 +01:00
4c7482b919
Update .gitea/workflows/build.yaml
All checks were successful
Build project / build (push) Successful in 18s
2024-12-31 02:25:37 +01:00
f10c623aa8
Update .gitea/workflows/build.yaml
All checks were successful
Build project / build (push) Successful in 18s
Build project / build (pull_request) Successful in 18s
2024-12-31 02:23:29 +01:00
4e090ed281
Update .gitea/workflows/build.yaml
Some checks failed
Build project / build (22.x) (push) Failing after 9s
Build project / build (22.x) (pull_request) Failing after 3s
2024-12-31 02:12:33 +01:00
1b9c616e16
Update .gitea/workflows/build.yaml
All checks were successful
Build project / build (push) Successful in 19s
2024-12-31 02:07:55 +01:00
7fe575a651
Update .gitea/workflows/build.yaml
Some checks failed
Build project / build (push) Failing after 17s
2024-12-31 02:07:05 +01:00
f33c54ba26
Update .gitea/workflows/build.yaml
Some checks failed
Build project / build (push) Failing after 36s
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: #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: #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: #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: #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: #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: #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
7e29802f8e
Merge pull request 'fix(): fix profile update' (#82) from v3.0.1 into main
Reviewed-on: #82
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2024-12-22 00:08:22 +01:00
17473db3b5
fix(): fix profile update 2024-12-22 00:07:01 +01:00
990bb8b552
Merge pull request 'v3.1.1' (#81) from v3.0.1 into main
Reviewed-on: #81
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2024-12-21 18:53:38 +01:00
3f3e369fb7
feat(): sort active players 2024-12-21 18:49:54 +01:00
e84afd359a
feat(): code cleanup 2024-12-21 18:38:03 +01:00
3ce2d4501a
feat(): Add active to profile 2024-12-21 18:37:05 +01:00
bb1245e528
Merge pull request 'feat(): Profiles page, added hover informations on user icons, show 50 records in leaderboard.' (#80) from v3.0.1 into main
Reviewed-on: #80
Reviewed-by: Aleksander Wilczyński <aleks@alekswilc.dev>
2024-12-17 23:34:28 +01:00
fe0d59cad3
feat(): Profiles page, added hover informations on user icons, show 50 records in leaderboard. 2024-12-17 23:28:13 +01:00
113 changed files with 2760 additions and 5564 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",
"scripts": {
"build": "yarn workspace backend build && yarn workspace frontend build",
"postbuild": "copyfiles --error ./LICENSE.txt ./dist && copyfiles --error ./LICENSE.txt ./dist/frontend/",
"start": "concurrently --kill-others-on-fail \"yarn workspace backend start\" \"yarn workspace frontend dev\""
},
"workspaces": [

View File

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

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 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
* 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
* 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
* it under the terms of the GNU Affero General Public License as published
@ -40,6 +40,24 @@ interface ActiveStation
steam: string;
}
import { Server } from "@simrail/types";
import { generateUrl } from "../../util/imgproxy.js";
const sortFunction = (a: ActiveStation | ActiveTrain, b: ActiveStation | ActiveTrain) =>
{
if (a.server.includes("pl") && !b.server.includes("pl"))
{
return -1;
}
if (!a.server.includes("pl") && b.server.includes("pl"))
{
return 1;
}
return 0;
};
export class ActivePlayersRoute
{
static load()
@ -48,15 +66,22 @@ export class ActivePlayersRoute
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[] = [];
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))
{
const p = await PlayerUtil.getPlayer(d.TrainData.ControlledBySteamID!);
if (p && process.env.IMGPROXY_KEY)
{
p.avatar = generateUrl(p.avatar);
}
p && a.push({
server: d.ServerCode,
player: p,
@ -71,31 +96,42 @@ export class ActivePlayersRoute
if (s)
{
a = a.filter(d => s.filter(c => c.test(d.server) || c.test(d.username) || c.test(d.steam) || c.test(d.steam) || c.test(d.trainName) || c.test(d.trainNumber) ).length === s.length);
a = a.filter(d => s.filter(c => c.test(d.server) || c.test(d.username) || c.test(d.steam) || c.test(d.steam) || c.test(d.trainName) || c.test(d.trainNumber)).length === s.length);
}
a = arrayGroupBy(a, d => d.server);
a = arrayGroupBy(a, d => d.server)
.sort(sortFunction);
res.json(
new SuccessResponseBuilder()
.setCode(200)
.setData({ records: a })
.setData({
records: a,
servers: Object.keys(client.stations),
})
.toJSON(),
);
});
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[] = [];
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))
{
// todo: optimize
const p = await PlayerUtil.getPlayer(d.DispatchedBy[ 0 ].SteamId!);
if (p && process.env.IMGPROXY_KEY)
{
p.avatar = generateUrl(p.avatar);
}
p && a.push({
server: server,
player: p,
@ -110,16 +146,20 @@ export class ActivePlayersRoute
if (s)
{
a = a.filter(d => s.filter(c => c.test(d.server) || c.test(d.username) || c.test(d.steam) || c.test(d.steam) || c.test(d.stationName) || c.test(d.stationShort) ).length === s.length);
a = a.filter(d => s.filter(c => c.test(d.server) || c.test(d.username) || c.test(d.steam) || c.test(d.steam) || c.test(d.stationName) || c.test(d.stationShort)).length === s.length);
}
a = arrayGroupBy(a, d => d.server);
a = arrayGroupBy(a, d => d.server)
.sort(sortFunction);
res.json(
new SuccessResponseBuilder()
.setCode(200)
.setData({ records: a })
.setData({
records: a,
servers: Object.keys(client.stations),
})
.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
* it under the terms of the GNU Affero General Public License as published
@ -18,6 +18,9 @@ import { Router } from "express";
import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js";
import { MAdmin } from "../../mongo/admin.js";
import { MProfile } from "../../mongo/profile.js";
import { PlayerUtil } from "../../util/PlayerUtil.js";
import { getVehicle } from "../../util/contants.js";
import { isTruthyAndGreaterThanZero } from "../../util/functions.js";
export class AdminRoute
{
@ -203,6 +206,101 @@ export class AdminRoute
);
});
app.post("/profile/:playerId/forceUpdate", async (req, res) =>
{
const token = req.headers[ "x-auth-token" ];
if (!token)
{
res.status(400).json(new ErrorResponseBuilder()
.setCode(400)
.setData("Missing token").toJSON());
return;
}
const admin = await MAdmin.findOne({ token });
if (!admin)
{
res.status(401).json(new ErrorResponseBuilder()
.setCode(401)
.setData("Invalid token").toJSON());
return;
}
const player = await MProfile.findOne({
id: req.params.playerId,
});
if (!player)
{
res.status(401).json(new ErrorResponseBuilder()
.setCode(401)
.setData("Invalid playerId").toJSON());
return;
}
const stats = await PlayerUtil.getPlayerStats(player.id);
if (!stats)
{
res.status(401).json(new ErrorResponseBuilder()
.setCode(401)
.setData("Invalid playerId (2)").toJSON());
return;
}
player.steamTrainDistance = stats?.stats?.find(x => x.name === "DISTANCE_M")?.value ?? 0;
player.steamDispatcherTime = stats?.stats?.find(x => x.name === "DISPATCHER_TIME")?.value ?? 0;
player.steamTrainScore = stats?.stats?.find(x => x.name === "SCORE")?.value ?? 0;
if (player.steamTrainDistance > player.trainDistance)
{
player.trainDistance = player.steamTrainDistance;
}
if (player.steamTrainScore > player.trainPoints)
{
player.trainPoints = player.steamTrainScore;
}
const sum = Object.keys(player.trainStats).filter(x => x !== "N/A").map(x => player.trainStats[ x ]).reduce((acc, obj) =>
{
acc.time += obj.time;
acc.distance += obj.distance;
acc.score += obj.score;
return acc;
}, { time: 0, distance: 0, score: 0 });
player.trainStats[ "N/A" ] = {
time: 0,
distance: player.trainDistance - sum.distance,
score: player.trainPoints - sum.score,
};
if (typeof player.createdAt !== "number")
{
player.createdAt = new Date(parseInt(player._id.toString().substring(0, 8), 16) * 1000).getTime();
}
const playerData = await PlayerUtil.getPlayerSteamData(player.id);
player.username = playerData?.personaname ?? player.username;
player.avatar = playerData?.avatarfull ?? player.avatar;
await MProfile.updateOne({ id: player.id }, player);
res.json(
new SuccessResponseBuilder()
.setCode(200)
.setData({})
.toJSON(),
);
});
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
* it under the terms of the GNU Affero General Public License as published
@ -17,8 +17,9 @@
import { Router } from "express";
import { PipelineStage } from "mongoose";
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 { generateUrl } from "../../util/imgproxy.js";
const generateSearch = (regex: RegExp) => [
{
@ -33,7 +34,7 @@ const sortyByMap: Record<string, any> = {
time: { trainTime: -1 },
points: { trainPoints: -1 },
distance: { trainDistance: -1 },
}
};
export class LeaderboardRoute
{
@ -43,14 +44,24 @@ export class LeaderboardRoute
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[] = [
{
$match: {
flags: { $nin: ["hidden", "leaderboard_hidden"] }
}
}
flags: { $nin: [ "hidden", "leaderboard_hidden" ] },
},
},
];
s && filter.push({
@ -61,31 +72,68 @@ 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)
.sort(sortBy)
.limit(10);
filter.push({
$sort: sortBy,
});
filter.push({
$facet: {
data: [
{ $match: {} },
{ $skip: (page - 1) * limit },
{ $limit: limit },
],
count: [
{ $count: "count" },
],
},
});
const records = await MProfile.aggregate(filter);
process.env.IMGPROXY_KEY && records[ 0 ].data && records[ 0 ].data.forEach((p: IProfile) =>
{
if (p.avatar.includes("imgproxy.alekswilc.dev"))
{
return;
}
p.avatar = generateUrl(p.avatar);
});
res.json(
new SuccessResponseBuilder<{ records: Omit<IProfile, "_id" | "__v">[] }>()
new SuccessResponseBuilder()
.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(),
);
});
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[] = [
{
$match: {
flags: { $nin: ["hidden", "leaderboard_hidden"] }
}
}
flags: { $nin: [ "hidden", "leaderboard_hidden" ] },
},
},
];
s && filter.push({
$match: {
@ -95,14 +143,43 @@ export class LeaderboardRoute
},
});
const records = await MProfile.aggregate(filter)
.sort({ dispatcherTime: -1 })
.limit(10);
filter.push({
$sort: {
dispatcherTime: -1,
},
});
filter.push({
$facet: {
data: [
{ $match: {} },
{ $skip: (page - 1) * limit },
{ $limit: limit },
],
count: [
{ $count: "count" },
],
},
});
const records = await MProfile.aggregate(filter);
process.env.IMGPROXY_KEY && records[ 0 ].data && records[ 0 ].data.forEach((p: IProfile) =>
{
if (p.avatar.includes("imgproxy.alekswilc.dev"))
{
return;
}
p.avatar = generateUrl(p.avatar);
});
res.json(
new SuccessResponseBuilder<{ records: Omit<IProfile, "_id" | "__v">[] }>()
new SuccessResponseBuilder()
.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(),
);
});

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
* 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 { MStationLog } from "../../mongo/stationLog.js";
import { IProfile } from "../../mongo/profile.js";
import { generateUrl } from "../../util/imgproxy.js";
export class LogRoute
@ -60,7 +61,13 @@ export class LogRoute
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;

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
* it under the terms of the GNU Affero General Public License as published
@ -15,8 +15,35 @@
*/
import { Router } from "express";
import { PipelineStage } from "mongoose";
import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js";
import { PlayerUtil } from "../../util/PlayerUtil.js";
import { IProfile, MProfile } from "../../mongo/profile.js";
import { escapeRegexString } from "../../util/functions.js";
import { generateUrl } from "../../util/imgproxy.js";
const generateSearch = (regex: RegExp) => [
{
id: { $regex: regex },
},
{
username: { $regex: regex },
},
];
type ActiveTrain = {
type: "train"
trainNumber: string
trainName: string
server: string
}
type ActiveStation = {
type: "station",
stationName: string
stationShort: string
server: string
}
export class ProfilesRoute
{
@ -40,25 +67,129 @@ export class ProfilesRoute
return;
}
if (player.flags.includes('hidden'))
if (player.flags.includes("hidden"))
{
res.status(403).json(new ErrorResponseBuilder()
.setCode(403).setData("Profile blocked!"));
return;
}
if (player.flags.includes('private'))
if (player.flags.includes("private"))
{
res.status(404).json(new ErrorResponseBuilder()
.setCode(404).setData("Profile is private!"));
return;
}
let active: ActiveStation | ActiveTrain = undefined!;
for (const x of Object.keys(client.trains))
{
const data = client.trains[ x ].find(x => x.TrainData.ControlledBySteamID === player.id);
if (data)
{
active = {
type: "train",
trainNumber: data.TrainNoLocal,
trainName: data.TrainName,
server: x,
};
}
}
for (const x of Object.keys(client.stations))
{
const data = client.stations[ x ].find(x => x.DispatchedBy[ 0 ]?.SteamId === player.id);
if (data)
{
active = {
type: "station",
stationName: data.Name,
stationShort: data.Prefix,
server: x,
};
}
}
if (process.env.IMGPROXY_KEY)
{
player.avatar = generateUrl(player.avatar, "rs:auto:256:256:1/f:png");
}
res.json(
new SuccessResponseBuilder()
.setCode(200)
.setData({
player
player,
active,
})
.toJSON(),
);
});
app.get("/", async (req, res) =>
{
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
const limit = parseInt(req.query.limit as string) || 12;
const page = parseInt(req.query.page as string) || 1;
if (page < 0 || limit < 0)
{
res.status(400).json(new ErrorResponseBuilder()
.setCode(400).setData("Invalid page and/or limit"));
return;
}
const filter: PipelineStage[] = [
{
$match: {
flags: { $nin: [ "hidden" ] },
},
},
];
s && filter.push({
$match: {
$and: [
...s.map(x => ({ $or: generateSearch(x) })),
],
},
});
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(
new SuccessResponseBuilder()
.setCode(200)
.setData({
records: records[ 0 ].data,
pages: Math.floor((records[ 0 ]?.count?.[ 0 ]?.count ?? 0) / limit),
})
.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
* 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 { PipelineStage } from "mongoose";
import { escapeRegexString } from "../../util/functions.js";
import { SuccessResponseBuilder } from "../responseBuilder.js";
import { MProfile } from "../../mongo/profile.js";
import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js";
import { IProfile, MProfile } from "../../mongo/profile.js";
import { generateUrl } from "../../util/imgproxy.js";
const generateSearch = (regex: RegExp) => [
{
@ -47,7 +48,19 @@ export class StationsRoute
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[] = [];
s && filter.push({
@ -58,16 +71,56 @@ export class StationsRoute
},
});
const records = await MStationLog.aggregate(filter)
.sort({ leftDate: -1 })
.limit(30);
server && filter.push({
$match: {
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(
new SuccessResponseBuilder<{ records: IStationLog[] }>()
new SuccessResponseBuilder()
.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(),
);
});

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
* it under the terms of the GNU Affero General Public License as published
@ -46,7 +46,6 @@ export class StatsRoute
);
});
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
* it under the terms of the GNU Affero General Public License as published
@ -17,9 +17,10 @@
import { Router } from "express";
import { PipelineStage } from "mongoose";
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 { MProfile } from "../../mongo/profile.js";
import { IProfile, MProfile } from "../../mongo/profile.js";
import { generateUrl } from "../../util/imgproxy.js";
const generateSearch = (regex: RegExp) => [
{
@ -44,10 +45,20 @@ export class TrainsRoute
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 limit = parseInt(req.query.limit as string) || 12;
const page = parseInt(req.query.page as string) || 1;
if (page < 0 || limit < 0)
{
res.status(400).json(new ErrorResponseBuilder()
.setCode(400).setData("Invalid page and/or limit"));
return;
}
s && filter.push({
$match: {
@ -57,17 +68,55 @@ export class TrainsRoute
},
});
const records = await MTrainLog.aggregate(filter)
.sort({ leftDate: -1 })
.limit(30);
server && filter.push({
$match: {
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(
new SuccessResponseBuilder<{ records: ITrainLog[] }>()
new SuccessResponseBuilder()
.setCode(200)
.setData({
records
records: records[ 0 ].data,
pages: Math.floor((records[ 0 ]?.count?.[ 0 ]?.count ?? 0) / limit),
servers: Object.keys(client.stations),
})
.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
* 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
* 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 dayjs from "dayjs";
import { TMProfile } from "./mongo/profile.js";
import { GitUtil } from "./util/git.js";
;(async () =>
{
@ -47,6 +48,7 @@ import { TMProfile } from "./mongo/profile.js";
}
ApiModule.load(); // TODO: use fastify
GitUtil.getData();
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
* it under the terms of the GNU Affero General Public License as published
@ -85,26 +85,37 @@ export class StationsModule
player.steamTrainScore = stats?.stats?.find(x => x.name === "SCORE")?.value ?? 0;
if ((player.steamTrainDistance > player.trainDistance) || (player.trainPoints > player.steamTrainScore))
if (player.steamTrainDistance > player.trainDistance)
{
player.trainStats[ "N/A" ] = {
time: 0, distance: player.steamTrainDistance > player.trainDistance ? player.steamTrainDistance - player.trainDistance : player.trainDistance,
score: player.trainPoints > player.steamTrainScore ? player.steamTrainScore - player.trainPoints : player.trainPoints,
};
if (player.steamTrainDistance > player.trainDistance)
{
player.trainDistance = player.steamTrainDistance;
}
if (player.trainPoints > player.steamTrainScore)
{
player.trainPoints = player.steamTrainScore;
}
player.trainDistance = player.steamTrainDistance;
}
if (player.steamTrainScore > player.trainPoints)
{
player.trainPoints = player.steamTrainScore;
}
const sum = Object.keys(player.trainStats).filter(x => x !== "N/A").map(x => player.trainStats[ x ]).reduce((acc, obj) =>
{
acc.time += obj.time;
acc.distance += obj.distance;
acc.score += obj.score;
return acc;
}, { time: 0, distance: 0, score: 0 });
player.trainStats[ "N/A" ] = {
time: 0,
distance: player.trainDistance - sum.distance,
score: player.trainPoints - sum.score,
};
player.flags = player.flags.filter(x => x !== "private");
if (typeof player.createdAt !== 'number') player.createdAt = new Date(parseInt(player._id.toString().substring(0, 8), 16) * 1000).getTime();
if (typeof player.createdAt !== "number")
{
player.createdAt = new Date(parseInt(player._id.toString().substring(0, 8), 16) * 1000).getTime();
}
}
const playerData = await PlayerUtil.getPlayerSteamData(player.id);

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
* it under the terms of the GNU Affero General Public License as published
@ -61,10 +61,14 @@ export class TrainsModule
const vehicleName = getVehicle(vehicle) ?? vehicle;
if (!isTruthyAndGreaterThanZero(distance))
{
distance = 0;
}
if (!isTruthyAndGreaterThanZero(points))
{
points = 0;
}
if (!player.trainStats)
@ -114,33 +118,45 @@ export class TrainsModule
player.steamDispatcherTime = stats?.stats?.find(x => x.name === "DISPATCHER_TIME")?.value ?? 0;
player.steamTrainScore = stats?.stats?.find(x => x.name === "SCORE")?.value ?? 0;
if ((player.steamTrainDistance > player.trainDistance) || (player.steamTrainScore > player.trainPoints))
{
player.trainStats[ "N/A" ] = {
time: 0, distance: player.steamTrainDistance > player.trainDistance ? player.steamTrainDistance - player.trainDistance : player.trainDistance,
score: player.steamTrainScore > player.trainPoints ? player.steamTrainScore - player.trainPoints : player.trainPoints,
};
if (player.steamTrainDistance > player.trainDistance)
{
player.trainDistance = player.steamTrainDistance;
}
if (player.steamTrainScore > player.trainPoints)
{
player.trainPoints = player.steamTrainScore;
}
if (player.steamTrainDistance > player.trainDistance)
{
player.trainDistance = player.steamTrainDistance;
}
if (player.steamTrainScore > player.trainPoints)
{
player.trainPoints = player.steamTrainScore;
}
const sum = Object.keys(player.trainStats).filter(x => x !== "N/A").map(x => player.trainStats[ x ]).reduce((acc, obj) =>
{
acc.time += obj.time;
acc.distance += obj.distance;
acc.score += obj.score;
return acc;
}, { time: 0, distance: 0, score: 0 });
player.trainStats[ "N/A" ] = {
time: 0,
distance: player.trainDistance - sum.distance,
score: player.trainPoints - sum.score,
};
player.flags = player.flags.filter(x => x !== "private");
if (typeof player.createdAt !== 'number') player.createdAt = new Date(parseInt(player._id.toString().substring(0, 8), 16) * 1000).getTime();
if (typeof player.createdAt !== "number")
{
player.createdAt = new Date(parseInt(player._id.toString().substring(0, 8), 16) * 1000).getTime();
}
}
const playerData = await PlayerUtil.getPlayerSteamData(player.id);
!stats && !player.flags.includes('private') && player.flags.push("private");
!stats && !player.flags.includes("private") && player.flags.push("private");
player.flags = [...new Set(player.flags)];
player.flags = [ ...new Set(player.flags) ];
player.username = playerData?.personaname ?? player.username;
player.avatar = playerData?.avatarfull ?? player.avatar;

View File

@ -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
* it under the terms of the GNU Affero General Public License as published
@ -35,6 +35,6 @@ export const MAdmin = model<IAdmin>("admin", schema);
export interface IAdmin
{
token: string
username: string
token: string;
username: string;
}

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
* 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
* it under the terms of the GNU Affero General Public License as published
@ -55,8 +55,8 @@ export const raw_schema = {
player: {
type: Schema.Types.ObjectId,
ref: "profile"
}
ref: "profile",
},
};
const schema = new Schema<IStationLog>(raw_schema);

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
* it under the terms of the GNU Affero General Public License as published
@ -63,8 +63,8 @@ export const raw_schema = {
},
player: {
type: Schema.Types.ObjectId,
ref: "profile"
}
ref: "profile",
},
};
const schema = new Schema<ITrainLog>(raw_schema);

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
* 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
* 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
* it under the terms of the GNU Affero General Public License as published
@ -18,7 +18,7 @@ import { IPlayerPayload, IPlayerStatsPayload } from "../types/player.js";
import { MProfile } from "../mongo/profile.js";
import { assert } from "node:console";
const STEAM_API_KEY = process.env.STEAM_APIKEY;
const steamKeys: string[] = JSON.parse(process.env.STEAM_APIKEY!);
const steamFetch = (url: string) =>
{
@ -28,12 +28,13 @@ const steamFetch = (url: string) =>
{
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))
.catch(() =>
{
console.log("STEAM request failed! ", url.replace(STEAM_API_KEY!, "[XXX]"), retries);
console.log("STEAM request failed! ", url.replace("[STEAMKEY]", steamKey), retries);
retries++;
setTimeout(() => req(), retries * 1000);
@ -54,7 +55,7 @@ export class PlayerUtil
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");
@ -85,14 +86,14 @@ export class PlayerUtil
if (stats)
{
trainStats['N/A'] = {
trainStats[ "N/A" ] = {
score: stats?.stats?.find(x => x.name === "SCORE")?.value ?? 0,
distance: stats?.stats?.find(x => x.name === "DISTANCE_M")?.value ?? 0,
time: 0,
};
dispatcherStats['N/A'] = {
time: (stats?.stats?.find(x => x.name === "DISPATCHER_TIME")?.value ?? 0) * 1000 * 60
dispatcherStats[ "N/A" ] = {
time: (stats?.stats?.find(x => x.name === "DISPATCHER_TIME")?.value ?? 0) * 1000 * 60,
};
trainPoints = stats?.stats?.find(x => x.name === "SCORE")?.value ?? 0;
@ -149,7 +150,7 @@ export class PlayerUtil
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)
{
@ -161,7 +162,7 @@ export class PlayerUtil
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)
{

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
* it under the terms of the GNU Affero General Public License as published
@ -65,7 +65,8 @@ export class SimrailClient extends EventEmitter
public constructor()
{
super();
this.setup().then(() => {
this.setup().then(() =>
{
void this.update(false);
});
@ -76,38 +77,48 @@ export class SimrailClient extends EventEmitter
private async setup()
{
if (!await redis.get('last_updated')) {
await redis.json.set('trains_occupied', '$', {});
await redis.json.set('trains', '$', []);
await redis.json.set('stations', '$', []);
await redis.json.set('stations_occupied', '$', {});
if (!await redis.get("last_updated"))
{
await redis.json.set("trains_occupied", "$", {});
await redis.json.set("trains", "$", []);
await redis.json.set("stations", "$", []);
await redis.json.set("stations_occupied", "$", {});
}
const lastUpdated = Date.now() - (Number(await redis.get('last_updated')) ?? 0);
const lastUpdated = Date.now() - (Number(await redis.get("last_updated")) ?? 0);
if (lastUpdated > 300_000) {
console.log('REDIS: last updated more than > 5 mins');
await redis.json.set('trains_occupied', '$', {});
await redis.json.set('trains', '$', []);
await redis.json.set('stations', '$', []);
await redis.json.set('stations_occupied', '$', {});
if (lastUpdated > 300_000)
{
console.log("REDIS: last updated more than > 5 mins");
await redis.json.set("trains_occupied", "$", {});
await redis.json.set("trains", "$", []);
await redis.json.set("stations", "$", []);
await redis.json.set("stations_occupied", "$", {});
}
if (!await redis.json.get('stations'))
redis.json.set('stations', '$', []);
if (!await redis.json.get('trains'))
redis.json.set('trains', '$', []);
if (!await redis.json.get('trains_occupied'))
redis.json.set('trains_occupied', '$', {});
if (!await redis.json.get('stations_occupied'))
redis.json.set('stations_occupied', '$', {});
if (!await redis.json.get("stations"))
{
redis.json.set("stations", "$", []);
}
if (!await redis.json.get("trains"))
{
redis.json.set("trains", "$", []);
}
if (!await redis.json.get("trains_occupied"))
{
redis.json.set("trains_occupied", "$", {});
}
if (!await redis.json.get("stations_occupied"))
{
redis.json.set("stations_occupied", "$", {});
}
this.stations = (await redis.json.get('stations') as unknown as SimrailClient['stations']);
this.stationsOccupied = (await redis.json.get('stations_occupied') as unknown as SimrailClient['stationsOccupied']);
this.trains = (await redis.json.get('trains') as unknown as SimrailClient['trains']);
this.trainsOccupied = (await redis.json.get('trains_occupied') as unknown as SimrailClient['trainsOccupied']);
this.stations = (await redis.json.get("stations") as unknown as SimrailClient["stations"]);
this.stationsOccupied = (await redis.json.get("stations_occupied") as unknown as SimrailClient["stationsOccupied"]);
this.trains = (await redis.json.get("trains") as unknown as SimrailClient["trains"]);
this.trainsOccupied = (await redis.json.get("trains_occupied") as unknown as SimrailClient["trainsOccupied"]);
redis.set('last_updated', Date.now().toString());
redis.set("last_updated", Date.now().toString());
}
private async processStation(server: Server, stations: ApiResponse<Station>)
@ -127,7 +138,7 @@ export class SimrailClient extends EventEmitter
{
this.stations[ server.ServerCode ] = stations.data;
redis.json.set("stations", "$", this.stations);
redis.set('last_updated', Date.now().toString());
redis.set("last_updated", Date.now().toString());
}
for (const x of stations.data)
@ -167,7 +178,7 @@ export class SimrailClient extends EventEmitter
this.stations[ server.ServerCode ] = stations.data;
redis.json.set("stations", "$", this.stations);
redis.set('last_updated', Date.now().toString());
redis.set("last_updated", Date.now().toString());
}
}
@ -188,7 +199,7 @@ export class SimrailClient extends EventEmitter
{
this.trains[ server.ServerCode ] = trains.data;
redis.json.set("trains", "$", this.trains);
redis.set('last_updated', Date.now().toString());
redis.set("last_updated", Date.now().toString());
return;
}
@ -252,13 +263,15 @@ export class SimrailClient extends EventEmitter
let points = oldPoints ? (playerStats?.stats.find(x => x.name === "SCORE")?.value ?? 0) - oldPoints : 0;
if (distance < 0) {
console.warn(`Player ${playerId}, Train ${data.TrainNoLocal} - distance < 0`);
if (distance < 0)
{
console.warn(`Player ${ playerId }, Train ${ data.TrainNoLocal } - distance < 0`);
distance = 0;
}
if (points < 0) {
console.warn(`Player ${playerId}, Train ${data.TrainNoLocal} - distance < 0`);
if (points < 0)
{
console.warn(`Player ${ playerId }, Train ${ data.TrainNoLocal } - distance < 0`);
points = 0;
}
@ -273,14 +286,14 @@ export class SimrailClient extends EventEmitter
this.trains[ server.ServerCode ] = trains.data;
redis.json.set("trains", "$", this.trains);
redis.json.set("trains_occupied", "$", this.trainsOccupied);
redis.set('last_updated', Date.now().toString());
redis.set("last_updated", Date.now().toString());
}
}
private async update(needSetup: boolean = false)
{
const servers = (await fetch("https://panel.simrail.eu:8084/servers-open").then(x => x.json()).catch(() => ({ data: [], result: false })) as ApiResponse<Server>)
.data ?? [] //?.filter(x => x.ServerName.includes("Polski")) ?? []; // TODO: remove this in v3
.data ?? []; //?.filter(x => x.ServerName.includes("Polski")) ?? []; // TODO: remove this in v3
if (!servers.length)
{
@ -290,7 +303,7 @@ export class SimrailClient extends EventEmitter
return;
}
if (needSetup)
if (needSetup)
{
await this.setup();
}
@ -299,8 +312,8 @@ export class SimrailClient extends EventEmitter
// TODO: check performance
for (const server of servers)
{
const stations = (await fetch('https://panel.simrail.eu:8084/stations-open?serverCode=' + server.ServerCode).then(x => x.json()).catch(() => ({ result: false }))) as ApiResponse<Station>;
const trains = (await fetch('https://panel.simrail.eu:8084/trains-open?serverCode=' + server.ServerCode).then(x => x.json()).catch(() => ({ result: false }))) as ApiResponse<Train>;
const stations = (await fetch("https://panel.simrail.eu:8084/stations-open?serverCode=" + server.ServerCode).then(x => x.json()).catch(() => ({ result: false }))) as ApiResponse<Station>;
const trains = (await fetch("https://panel.simrail.eu:8084/trains-open?serverCode=" + server.ServerCode).then(x => x.json()).catch(() => ({ result: false }))) as ApiResponse<Train>;
await this.processStation(server, stations);
await this.processTrain(server, trains);

View File

@ -1,4 +1,21 @@
import wcmatch from 'wildcard-match'
/*
* 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";
/*
E186_134 = "Traxx/E186-134",
@ -48,85 +65,82 @@ E186_134 = "Traxx/E186-134",
export const trainsList = [
{
train: 'Traxx (E186)',
train: "Traxx (E186)",
pattern: [
'Traxx/E186-*',
]
"Traxx/E186-*",
],
},
{
train: 'Dragon2 (E6ACTa, E6ACTadb)',
train: "Dragon2 (E6ACTa, E6ACTadb)",
pattern: [
'Dragon2/E6ACTa-*',
'Dragon2/E6ACTadb-*'
]
"Dragon2/E6ACTa-*",
"Dragon2/E6ACTadb-*",
],
},
{
train: 'Dragon2 (ET25)',
train: "Dragon2 (ET25)",
pattern: [
'Dragon2/ET25-*',
]
"Dragon2/ET25-*",
],
},
{
train: 'Pendolino (ED250)',
train: "Pendolino (ED250)",
pattern: [
'Pendolino/ED250-*',
]
"Pendolino/ED250-*",
],
},
{
train: 'EN57',
train: "EN57",
pattern: [
'EN57/EN57-*',
]
"EN57/EN57-*",
],
},
{
train: 'EN71',
train: "EN71",
pattern: [
'EN57/EN71-*',
]
"EN57/EN71-*",
],
},
{
train: 'EN76',
train: "EN76",
pattern: [
'Elf/EN76-*',
]
"Elf/EN76-*",
],
},
{
train: 'EN96',
train: "EN96",
pattern: [
'Elf/EN96-*',
]
"Elf/EN96-*",
],
},
{
train: 'EP07',
train: "EP07",
pattern: [
'4E/EP07-*',
]
"4E/EP07-*",
],
},
{
train: 'EP08',
train: "EP08",
pattern: [
'4E/EP08-*',
]
"4E/EP08-*",
],
},
{
train: 'ET22',
train: "ET22",
pattern: [
'201E/ET22-*',
]
"201E/ET22-*",
],
},
{
train: 'EU07',
train: "EU07",
pattern: [
'4E/EU07-*',
]
}
]
"4E/EU07-*",
],
},
];
export const getVehicle = (name: string) => {
export const getVehicle = (name: string) =>
{
return trainsList.find(x => wcmatch(x.pattern)(name))?.train;
};

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
* it under the terms of the GNU Affero General Public License as published
@ -26,17 +26,23 @@ export const removeProperties = <T>(data: any, names: string[]) =>
// https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
export const escapeRegexString = (str: string) => {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export const escapeRegexString = (str: string) =>
{
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
};
export const isTruthyAndGreaterThanZero = (data: number) => {
if (!data) return false;
export const isTruthyAndGreaterThanZero = (data: number) =>
{
if (!data)
{
return false;
}
return data > 0;
}
};
export const arrayGroupBy = <T>(array: T[], predicate: (value: T, index: number, array: T[]) => string) =>
Object.values((array.reduce((acc, value, index, array) => {
(acc[predicate(value, index, array)] ||= []).push(value);
Object.values((array.reduce((acc, value, index, array) =>
{
(acc[ predicate(value, index, array) ] ||= []).push(value);
return acc;
}, {} as { [key: string]: T[] }))).flat();
}, {} as { [ key: string ]: T[] }))).flat();

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
* 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
{
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
{
const data = execSync("git describe --tags --exact-match").toString();
return data.replace("\n", "");
} catch
{
return undefined;
}
return process.env.CURRENT_VERSION;
// try
// {
// const data = execSync("git describe --tags --exact-match").toString();
// return data.replace("\n", "");
// } catch
// {
// return undefined;
// }
}
public static getLatestCommit()
private static getLatestCommit()
{
try
{
const data = execSync("git rev-parse --short HEAD").toString();
return data.replace("\n", "");
} catch
{
return undefined;
}
return process.env.CURRENT_COMMIT;
// try
// {
// const data = execSync("git rev-parse --short HEAD").toString();
// return data.replace("\n", "");
// } catch
// {
// return undefined;
// }
}
public static getData()
{
if (this.cache && (this.cache.lastUpdated - Date.now()) < 30_000)
if (this.cache)
{
return this.cache;
}
@ -55,7 +57,6 @@ export class GitUtil
const data = {
version: this.getLatestVersion(),
commit: this.getLatestCommit(),
lastUpdated: Date.now(),
};
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
* it under the terms of the GNU Affero General Public License as published

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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
~ it under the terms of the GNU Affero General Public License as published
@ -19,6 +19,7 @@
<head>
<meta charset="UTF-8"/>
<link rel="icon" type="image/jpg" href="/favicon.png"/>
<link rel="preconnect" href="https://umami.alekswilc.dev/script.js">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>simrail.pro | Simrail Logs</title>

View File

@ -5,29 +5,23 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"rawbuild": "vite build",
"preview": "vite preview",
"build": "docker build --progress=plain -t simrailpro:frontend ."
},
"dependencies": {
"apexcharts": "^3.41.0",
"dayjs": "^1.11.13",
"flatpickr": "^4.6.13",
"headlessui": "^0.0.0",
"i18next": "^23.15.1",
"i18next-browser-languagedetector": "^8.0.0",
"match-sorter": "^6.3.1",
"react": "^18.2.0",
"react-apexcharts": "^1.4.1",
"react-cookie": "^7.2.2",
"react-country-flag": "^3.1.0",
"react-dom": "^18.2.0",
"react-helmet-async": "^2.0.5",
"react-hot-toast": "^2.4.1",
"react-i18next": "^15.0.2",
"react-icons": "^4.10.1",
"react-router-dom": "^6.14.2",
"react-toastify": "^10.0.6",
"sort-by": "^0.0.2",
"swr": "^2.2.5",
"use-debounce": "^10.0.4"
},
@ -42,7 +36,7 @@
"prettier": "^3.0.0",
"prettier-plugin-tailwindcss": "^0.4.1",
"tailwindcss": "^3.4.1",
"vite": "^4.4.7",
"vite": "^6.2.0",
"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
* 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
* it under the terms of the GNU Affero General Public License as published
@ -21,11 +21,10 @@ import { Loader } from "./components/mini/loaders/PageLoader.tsx";
import { Home } from "./pages/Home";
import DefaultLayout from "./layout/DefaultLayout";
import "./i18n";
import { TrainLeaderboard } from "./pages/leaderboard/TrainLeaderboard.tsx";
import { StationLeaderboard } from "./pages/leaderboard/StationsLeaderboard.tsx";
import { Leaderboard } from "./pages/leaderboard/Leaderboard.tsx";
import { TrainLogs } from "./pages/logs/TrainLogs.tsx";
import { StationLogs } from "./pages/logs/StationLogs.tsx";
import { Profile } from "./pages/profile/Profile.tsx";
import { Profile } from "./pages/profiles/Profile.tsx";
import { Log } from "./pages/log/Log.tsx";
import "react-toastify/dist/ReactToastify.css";
import { ToastContainer } from "react-toastify";
@ -36,6 +35,7 @@ import { ActiveStationsPlayers } from "./pages/activePlayers/ActiveStationsPlaye
import { ActiveTrainPlayers } from "./pages/activePlayers/ActiveTrainPlayers.tsx";
import { AuthProvider } from "./hooks/useAuth.tsx";
import { NotFoundError } from "./pages/errors/NotFound.tsx";
import { Profiles } from "./pages/profiles/Profiles.tsx";
function App()
{
@ -55,135 +55,135 @@ function App()
return <HelmetProvider>
<AuthProvider>
{ loading ? (
<Loader/>
) : (
<>
<ToastContainer
position="top-center"
autoClose={ 1500 }
hideProgressBar={ false }
newestOnTop={ false }
closeOnClick
rtl={ false }
pauseOnHover
theme={ theme as "light" | "dark" }
/>
<DefaultLayout>
<Routes>
<Route
index
element={
<>
<PageMeta title="simrail.pro | Home"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
<Home/>
</>
}
/>
<Route
path="/leaderboard/trains"
element={
<>
<PageMeta title="simrail.pro | Train Leaderboard"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
<TrainLeaderboard/>
</>
}
/>
{ loading ? (
<Loader/>
) : (
<>
<ToastContainer
position="top-center"
autoClose={ 1500 }
hideProgressBar={ false }
newestOnTop={ false }
closeOnClick
rtl={ false }
pauseOnHover
theme={ theme as "light" | "dark" }
/>
<DefaultLayout>
<Routes>
<Route
index
element={
<>
<PageMeta title="simrail.pro | Home"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
<Home/>
</>
}
/>
<Route
path="/leaderboard/"
element={
<>
<PageMeta title="simrail.pro | Train Leaderboard"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
<Leaderboard/>
</>
}
/>
<Route
path="/logs/trains"
element={
<>
<PageMeta title="simrail.pro | Trains Logs"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
<TrainLogs/>
</>
}
/>
<Route
path="/logs/trains"
element={
<>
<PageMeta title="simrail.pro | Trains Logs"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
<TrainLogs/>
</>
}
/>
<Route
path="/logs/stations"
element={
<>
<PageMeta title="simrail.pro | Stations Logs"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
<StationLogs/>
</>
}
/>
<Route
path="/logs/stations"
element={
<>
<PageMeta title="simrail.pro | Stations Logs"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
<StationLogs/>
</>
}
/>
<Route
path="/leaderboard/stations"
element={
<>
<PageMeta title="simrail.pro | Station Leaderboard"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
<StationLeaderboard/>
</>
}
/>
<Route
path="/active/trains"
element={
<>
<PageMeta title="simrail.pro | Active Trains"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
<ActiveTrainPlayers/>
</>
}
/>
<Route
path="/active/stations"
element={
<>
<PageMeta title="simrail.pro | Active Station"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
<ActiveStationsPlayers/>
</>
}
/>
<Route
path="/active/trains"
element={
<>
<PageMeta title="simrail.pro | Active Trains"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
<ActiveTrainPlayers/>
</>
}
/>
<Route
path="/profile/:id"
element={
<>
<PageMeta title="simrail.pro | Profile"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
{/* page meta is modified in component! */ }
<Profile/>
</>
}
/>
<Route
path="/active/stations"
element={
<>
<PageMeta title="simrail.pro | Active Station"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
<ActiveStationsPlayers/>
</>
}
/>
<Route
path="/profiles/"
element={
<>
<PageMeta title="simrail.pro | Profiles"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
{/* page meta is modified in component! */ }
<Profiles/>
</>
}
/>
<Route
path="/profile/:id"
element={
<>
<PageMeta title="simrail.pro | Profile"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
{/* page meta is modified in component! */ }
<Profile/>
</>
}
/>
<Route
path="/log/:id"
element={
<>
<PageMeta title="simrail.pro | Log"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
{/* page title is modified after API response */ }
<Log/>
</>
}
/>
<Route
path="/log/:id"
element={
<>
<PageMeta title="simrail.pro | Log"
description="Simrail Stats - The best SimRail logs and statistics site!"/>
{/* page title is modified after API response */ }
<Log/>
</>
}
/>
<Route
path="*"
element={
<>
<NotFoundError/>
</>
}
/>
</Routes>
</DefaultLayout>
</>
) }
<Route
path="*"
element={
<>
<NotFoundError/>
</>
}
/>
</Routes>
</DefaultLayout>
</>
) }
</AuthProvider>
</HelmetProvider>;
}

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
* 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
* 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
* 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
* it under the terms of the GNU Affero General Public License as published
@ -45,10 +45,10 @@ const DarkModeSwitcher = () =>
}` }
>
<span className="dark:hidden">
<LightIcon/>
<LightIcon />
</span>
<span className="hidden dark:inline-block">
<DarkIcon/>
<DarkIcon />
</span>
</span>
</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
* 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">
<ul className="flex items-center gap-2 2xsm:gap-4">
<a className="cursor-pointer" onClick={ () => i18n.changeLanguage("pl") }>
<ReactCountryFlag countryCode={ "PL" } svg/>
<ReactCountryFlag countryCode={ "PL" } svg alt={ "PL" }/>
</a>
<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>
</ul>
<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
* 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
* 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
* 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
* it under the terms of the GNU Affero General Public License as published

View File

@ -0,0 +1,43 @@
/*
* 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 { ReactNode, useState } from "react";
import { FaUserShield, FaUserSlash, FaUserLock } from "react-icons/fa6";
import { useTranslation } from 'react-i18next';
export const UseHover = ({ children, hover }: { children: ReactNode, hover: ReactNode }) => {
const [isHovered, setIsHovered] = useState(true);
return <div className={"inline"} onMouseEnter={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(true)}>
{isHovered ? children : hover}
</div>
}
export const UserIcons = ({ flags }: { flags: string[] }) =>
{
const { t } = useTranslation();
return <> { flags.includes('administrator') &&
<UseHover hover={ <button
className="inline-flex rounded-full bg-[#DC3545] py-1 px-3 text-sm font-medium text-white hover:bg-opacity-90">{t("icons.admin")}</button> }><FaUserShield
className={ "inline text-meta-1 ml-1" }/></UseHover> } { flags.includes("leaderboard_hidden") &&
<UseHover hover={ <button
className="inline-flex rounded-full bg-[#F9C107] py-1 px-3 text-sm font-medium text-[#212B36] hover:bg-opacity-90">{t("icons.leaderboard_hidden")}</button> }><FaUserLock
className={ "inline text-meta-6 ml-1" }/></UseHover> } { flags.includes("hidden") &&
<UseHover hover={ <button
className="inline-flex rounded-full bg-[#DC3545] py-1 px-3 text-sm font-medium text-white hover:bg-opacity-90">{ t("icons.hidden") }</button> }>
<FaUserSlash className={ "inline text-meta-1 ml-1" }/></UseHover> }</>;
};

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
* 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 { ErrorAlertIcon } from "../icons/AlertIcons.tsx";
export const LoadError = () =>
{
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
* it under the terms of the GNU Affero General Public License as published

View File

@ -1,61 +1,89 @@
/*
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* See LICENSE for more.
*/
import { Dispatch, SetStateAction } from "react";
export const ConfirmModal = ({ showModal, setShowModal, onConfirm, title, description }: { showModal: boolean; setShowModal: Dispatch<SetStateAction<boolean>>; onConfirm: () => void; title: string; description: string; }) => {
export const ConfirmModal = ({ showModal, setShowModal, onConfirm, title, description }: {
showModal: boolean;
setShowModal: Dispatch<SetStateAction<boolean>>;
onConfirm: () => void;
title: string;
description: string;
}) =>
{
return (
<>
{showModal ? (
<>
<div
className="justify-center items-center flex overflow-x-hidden overflow-y-auto fixed inset-0 z-50 outline-none focus:outline-none"
>
<div className="relative w-auto my-6 mx-auto max-w-3xl">
<>
{ showModal ? (
<>
<div
className="justify-center items-center flex overflow-x-hidden overflow-y-auto fixed inset-0 z-50 outline-none focus:outline-none"
>
<div className="relative w-auto my-6 mx-auto max-w-3xl">
<div className="border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-strokedark outline-none focus:outline-none">
<div className="border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-strokedark outline-none focus:outline-none">
<div className="flex items-start justify-between p-5">
<h3 className="text-3xl font-semibold text-meta-2">
{ title }
</h3>
<button
className="p-1 ml-auto bg-transparent border-0 text-meta-2 opacity-5 float-right text-3xl leading-none font-semibold outline-none focus:outline-none"
onClick={() => setShowModal(false)}
>
<div className="flex items-start justify-between p-5">
<h3 className="text-3xl font-semibold text-meta-2">
{ title }
</h3>
<button
className="p-1 ml-auto bg-transparent border-0 text-meta-2 opacity-5 float-right text-3xl leading-none font-semibold outline-none focus:outline-none"
onClick={ () => setShowModal(false) }
>
<span className="bg-transparent text-meta-2 opacity-5 h-6 w-6 text-2xl block outline-none focus:outline-none">
×
</span>
</button>
</div>
</button>
</div>
<div className="relative p-6 flex-auto">
<p className="my-4 text-blueGray-500 text-lg leading-relaxed text-meta-2">
{ description }
</p>
</div>
<div className="relative p-6 flex-auto">
<p className="my-4 text-blueGray-500 text-lg leading-relaxed text-meta-2">
{ description }
</p>
</div>
<div className="flex items-center justify-end p-6 rounded-b">
<button
className="text-red-500 background-transparent font-bold uppercase px-6 py-2 text-sm outline-none focus:outline-none mr-1 mb-1 ease-linear transition-all duration-150"
type="button"
onClick={() => setShowModal(false)}
>
Close
</button>
<button
className="bg-emerald-500 text-white active:bg-emerald-600 font-bold uppercase text-sm px-6 py-3 rounded shadow hover:shadow-lg outline-none focus:outline-none mr-1 mb-1 ease-linear transition-all duration-150"
type="button"
onClick={() => { setShowModal(false); onConfirm(); }}
>
Confirm
</button>
<div className="flex items-center justify-end p-6 rounded-b">
<button
className="text-red-500 background-transparent font-bold uppercase px-6 py-2 text-sm outline-none focus:outline-none mr-1 mb-1 ease-linear transition-all duration-150"
type="button"
onClick={ () => setShowModal(false) }
>
Close
</button>
<button
className="bg-emerald-500 text-white active:bg-emerald-600 font-bold uppercase text-sm px-6 py-3 rounded shadow hover:shadow-lg outline-none focus:outline-none mr-1 mb-1 ease-linear transition-all duration-150"
type="button"
onClick={ () =>
{
setShowModal(false);
onConfirm();
} }
>
Confirm
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="opacity-25 fixed inset-0 z-40 bg-black"></div>
</>
) : null}
</>
<div className="opacity-25 fixed inset-0 z-40 bg-black"></div>
</>
) : null }
</>
);
}
};

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
* it under the terms of the GNU Affero General Public License as published
@ -21,7 +21,7 @@ import { useTranslation } from "react-i18next";
import { HamburgerGoBackIcon } from "../icons/SidebarIcons.tsx";
import { ArrowIcon } from "../icons/ArrowIcon.tsx";
import { FaHome, FaClipboardList } from "react-icons/fa";
import { FaChartSimple, FaTrain, FaBuildingFlag, FaBolt } from "react-icons/fa6";
import { FaChartSimple, FaTrain, FaBuildingFlag, FaBolt, FaUsers } from "react-icons/fa6";
import { useAuth } from "../../../hooks/useAuth.tsx";
interface SidebarProps
@ -101,8 +101,7 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
return (
<aside
ref={ sidebar }
className={ `absolute left-0 top-0 z-9999 flex h-screen w-72.5 flex-col overflow-y-hidden bg-black duration-300 ease-linear dark:bg-boxdark lg:static lg:translate-x-0 ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
className={ `absolute left-0 top-0 z-9999 flex h-screen w-72.5 flex-col overflow-y-hidden bg-black duration-300 ease-linear dark:bg-boxdark lg:static lg:translate-x-0 ${ sidebarOpen ? "translate-x-0" : "-translate-x-full"
}` }
>
{/* <!-- SIDEBAR HEADER --> */ }
@ -126,9 +125,8 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
<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 === "/" &&
"bg-graydark dark:bg-meta-4"
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 === "/" &&
"bg-graydark dark:bg-meta-4"
}` }
>
<FaHome/>
@ -143,7 +141,32 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
{ t("sidebar.info") }
</h3>
<li>
<NavLink
to="/profiles"
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 === "/profiles" &&
"bg-graydark dark:bg-meta-4"
}` }
>
<FaUsers/>
{ t("sidebar.profiles") }
</NavLink>
</li>
<li>
<NavLink
to="/leaderboard"
className={ `group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${ pathname.includes("/leaderboard") &&
"bg-graydark dark:bg-meta-4"
}` }
>
<FaChartSimple/>
{ t("sidebar.leaderboard") }
</NavLink>
</li>
<SidebarLinkGroup
isOpen={ true }
activeCondition={
pathname === "/logs" || pathname.includes("logs")
}
@ -154,10 +177,9 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
<React.Fragment>
<NavLink
to="#"
className={ `group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${
(pathname === "/logs" ||
pathname.includes("logs")) &&
"bg-graydark dark:bg-meta-4"
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 === "/logs" ||
pathname.includes("logs")) &&
"bg-graydark dark:bg-meta-4"
}` }
onClick={ (e) =>
{
@ -173,8 +195,7 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
</NavLink>
<div
className={ `translate transform overflow-hidden ${
!open && "hidden"
className={ `translate transform overflow-hidden ${ !open && "hidden"
}` }
>
<ul className="mt-4 mb-5.5 flex flex-col gap-2.5 pl-6">
@ -210,72 +231,8 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
</SidebarLinkGroup>
<SidebarLinkGroup
activeCondition={
pathname === "/leaderboard" || pathname.includes("leaderboard")
}
>
{ (handleClick, open) =>
{
return (
<React.Fragment>
<NavLink
to="#"
className={ `group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${
(pathname === "/leaderboard" ||
(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>
isOpen={ true }
<SidebarLinkGroup
activeCondition={
pathname === "/active/trains" || pathname.includes("active/trains")
}
@ -286,10 +243,9 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
<React.Fragment>
<NavLink
to="#"
className={ `group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${
(pathname === "/active" ||
pathname.includes("/active/")) &&
"bg-graydark dark:bg-meta-4"
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 === "/active" ||
pathname.includes("/active/")) &&
"bg-graydark dark:bg-meta-4"
}` }
onClick={ (e) =>
{
@ -299,13 +255,12 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
: setSidebarExpanded(true);
} }
>
<FaBolt />
<FaBolt/>
{ t("sidebar.active_players") }
<ArrowIcon rotated={ open }/>
</NavLink>
<div
className={ `translate transform overflow-hidden ${
!open && "hidden"
className={ `translate transform overflow-hidden ${ !open && "hidden"
}` }
>
<ul className="mt-4 mb-5.5 flex flex-col gap-2.5 pl-6">
@ -347,286 +302,19 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
</h3>
<li>
<p className="group relative flex items-center rounded-sm py-2 px-4 text-sm text-bodydark1 duration-300 ease-in-out ">{t("sidebar.logged", { username })}</p>
<p className="group relative flex items-center rounded-sm py-2 px-4 text-sm text-bodydark1 duration-300 ease-in-out ">{ t("sidebar.logged", { username }) }</p>
</li>
<button onClick={() => {
window.localStorage.setItem('auth_token', 'undefined');
<button onClick={ () =>
{
window.localStorage.setItem("auth_token", "undefined");
window.location.reload();
}} className="inline-flex items-center justify-center rounded-md bg-primary py-2 px-8 text-center text-sm text-white hover:bg-opacity-90 lg:px-8 xl:px-10"
>{t("sidebar.logout")}
} }
className="inline-flex items-center justify-center rounded-md bg-primary py-2 px-8 text-center text-sm text-white hover:bg-opacity-90 lg:px-8 xl:px-10"
>{ t("sidebar.logout") }
</button>
</ul> }
</div>
{/* TODO: add admin panel with simple auth */ }
{/*{false && <div>*/ }
{/* <h3 className="mb-4 ml-4 text-sm font-semibold text-bodydark2">*/ }
{/* {t('sidebar.admin')}*/ }
{/* </h3>*/ }
{/* <ul className="mb-6 flex flex-col gap-1.5">*/ }
{/* */ }{/*{/* <!-- Menu Item Chart --> */ }
{/* <li>*/ }
{/* <NavLink*/ }
{/* to="/chart"*/ }
{/* 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('chart') && 'bg-graydark dark:bg-meta-4'*/ }
{/* }`}*/ }
{/* >*/ }
{/* <svg*/ }
{/* className="fill-current"*/ }
{/* width="18"*/ }
{/* height="19"*/ }
{/* viewBox="0 0 18 19"*/ }
{/* fill="none"*/ }
{/* xmlns="http://www.w3.org/2000/svg"*/ }
{/* >*/ }
{/* <g clipPath="url(#clip0_130_9801)">*/ }
{/* <path*/ }
{/* d="M10.8563 0.55835C10.5188 0.55835 10.2095 0.8396 10.2095 1.20522V6.83022C10.2095 7.16773 10.4907 7.4771 10.8563 7.4771H16.8751C17.0438 7.4771 17.2126 7.39272 17.3251 7.28022C17.4376 7.1396 17.4938 6.97085 17.4938 6.8021C17.2688 3.28647 14.3438 0.55835 10.8563 0.55835ZM11.4751 6.15522V1.8521C13.8095 2.13335 15.6938 3.8771 16.1438 6.18335H11.4751V6.15522Z"*/ }
{/* fill=""*/ }
{/* />*/ }
{/* <path*/ }
{/* d="M15.3845 8.7427H9.1126V2.69582C9.1126 2.35832 8.83135 2.07707 8.49385 2.07707C8.40947 2.07707 8.3251 2.07707 8.24072 2.07707C3.96572 2.04895 0.506348 5.53645 0.506348 9.81145C0.506348 14.0864 3.99385 17.5739 8.26885 17.5739C12.5438 17.5739 16.0313 14.0864 16.0313 9.81145C16.0313 9.6427 16.0313 9.47395 16.0032 9.33332C16.0032 8.99582 15.722 8.7427 15.3845 8.7427ZM8.26885 16.3083C4.66885 16.3083 1.77197 13.4114 1.77197 9.81145C1.77197 6.3802 4.47197 3.53957 7.8751 3.3427V9.36145C7.8751 9.69895 8.15635 10.0083 8.52197 10.0083H14.7938C14.6813 13.4958 11.7845 16.3083 8.26885 16.3083Z"*/ }
{/* fill=""*/ }
{/* />*/ }
{/* </g>*/ }
{/* <defs>*/ }
{/* <clipPath id="clip0_130_9801">*/ }
{/* <rect*/ }
{/* width="18"*/ }
{/* height="18"*/ }
{/* fill="white"*/ }
{/* transform="translate(0 0.052124)"*/ }
{/* />*/ }
{/* </clipPath>*/ }
{/* </defs>*/ }
{/* </svg>*/ }
{/* Chart*/ }
{/* </NavLink>*/ }
{/* </li>*/ }
{/* */ }{/*{/* <!-- Menu Item Chart --> */ }
{/* */ }{/*{/* <!-- Menu Item Ui Elements --> */ }
{/* <SidebarLinkGroup*/ }
{/* activeCondition={pathname === '/ui' || pathname.includes('ui')}*/ }
{/* >*/ }
{/* {(handleClick, open) => {*/ }
{/* return (*/ }
{/* <React.Fragment>*/ }
{/* <NavLink*/ }
{/* to="#"*/ }
{/* className={`group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${*/ }
{/* (pathname === '/ui' || pathname.includes('ui')) &&*/ }
{/* 'bg-graydark dark:bg-meta-4'*/ }
{/* }`}*/ }
{/* onClick={(e) => {*/ }
{/* e.preventDefault();*/ }
{/* sidebarExpanded*/ }
{/* ? handleClick()*/ }
{/* : setSidebarExpanded(true);*/ }
{/* }}*/ }
{/* >*/ }
{/* <svg*/ }
{/* className="fill-current"*/ }
{/* width="18"*/ }
{/* height="19"*/ }
{/* viewBox="0 0 18 19"*/ }
{/* fill="none"*/ }
{/* xmlns="http://www.w3.org/2000/svg"*/ }
{/* >*/ }
{/* <g clipPath="url(#clip0_130_9807)">*/ }
{/* <path*/ }
{/* d="M15.7501 0.55835H2.2501C1.29385 0.55835 0.506348 1.34585 0.506348 2.3021V7.53335C0.506348 8.4896 1.29385 9.2771 2.2501 9.2771H15.7501C16.7063 9.2771 17.4938 8.4896 17.4938 7.53335V2.3021C17.4938 1.34585 16.7063 0.55835 15.7501 0.55835ZM16.2563 7.53335C16.2563 7.8146 16.0313 8.0396 15.7501 8.0396H2.2501C1.96885 8.0396 1.74385 7.8146 1.74385 7.53335V2.3021C1.74385 2.02085 1.96885 1.79585 2.2501 1.79585H15.7501C16.0313 1.79585 16.2563 2.02085 16.2563 2.3021V7.53335Z"*/ }
{/* fill=""*/ }
{/* />*/ }
{/* <path*/ }
{/* d="M6.13135 10.9646H2.2501C1.29385 10.9646 0.506348 11.7521 0.506348 12.7083V15.8021C0.506348 16.7583 1.29385 17.5458 2.2501 17.5458H6.13135C7.0876 17.5458 7.8751 16.7583 7.8751 15.8021V12.7083C7.90322 11.7521 7.11572 10.9646 6.13135 10.9646ZM6.6376 15.8021C6.6376 16.0833 6.4126 16.3083 6.13135 16.3083H2.2501C1.96885 16.3083 1.74385 16.0833 1.74385 15.8021V12.7083C1.74385 12.4271 1.96885 12.2021 2.2501 12.2021H6.13135C6.4126 12.2021 6.6376 12.4271 6.6376 12.7083V15.8021Z"*/ }
{/* fill=""*/ }
{/* />*/ }
{/* <path*/ }
{/* d="M15.75 10.9646H11.8688C10.9125 10.9646 10.125 11.7521 10.125 12.7083V15.8021C10.125 16.7583 10.9125 17.5458 11.8688 17.5458H15.75C16.7063 17.5458 17.4938 16.7583 17.4938 15.8021V12.7083C17.4938 11.7521 16.7063 10.9646 15.75 10.9646ZM16.2562 15.8021C16.2562 16.0833 16.0312 16.3083 15.75 16.3083H11.8688C11.5875 16.3083 11.3625 16.0833 11.3625 15.8021V12.7083C11.3625 12.4271 11.5875 12.2021 11.8688 12.2021H15.75C16.0312 12.2021 16.2562 12.4271 16.2562 12.7083V15.8021Z"*/ }
{/* fill=""*/ }
{/* />*/ }
{/* </g>*/ }
{/* <defs>*/ }
{/* <clipPath id="clip0_130_9807">*/ }
{/* <rect*/ }
{/* width="18"*/ }
{/* height="18"*/ }
{/* fill="white"*/ }
{/* transform="translate(0 0.052124)"*/ }
{/* />*/ }
{/* </clipPath>*/ }
{/* </defs>*/ }
{/* </svg>*/ }
{/* UI Elements*/ }
{/* <svg*/ }
{/* className={`absolute right-4 top-1/2 -translate-y-1/2 fill-current ${*/ }
{/* open && 'rotate-180'*/ }
{/* }`}*/ }
{/* width="20"*/ }
{/* height="20"*/ }
{/* viewBox="0 0 20 20"*/ }
{/* fill="none"*/ }
{/* xmlns="http://www.w3.org/2000/svg"*/ }
{/* >*/ }
{/* <path*/ }
{/* fillRule="evenodd"*/ }
{/* clipRule="evenodd"*/ }
{/* d="M4.41107 6.9107C4.73651 6.58527 5.26414 6.58527 5.58958 6.9107L10.0003 11.3214L14.4111 6.91071C14.7365 6.58527 15.2641 6.58527 15.5896 6.91071C15.915 7.23614 15.915 7.76378 15.5896 8.08922L10.5896 13.0892C10.2641 13.4147 9.73651 13.4147 9.41107 13.0892L4.41107 8.08922C4.08563 7.76378 4.08563 7.23614 4.41107 6.9107Z"*/ }
{/* fill=""*/ }
{/* />*/ }
{/* </svg>*/ }
{/* </NavLink>*/ }
{/* */ }{/*{/* <!-- Dropdown Menu Start --> */ }
{/* <div*/ }
{/* className={`translate transform overflow-hidden ${*/ }
{/* !open && 'hidden'*/ }
{/* }`}*/ }
{/* >*/ }
{/* <ul className="mb-5.5 mt-4 flex flex-col gap-2.5 pl-6">*/ }
{/* <li>*/ }
{/* <NavLink*/ }
{/* to="/ui/alerts"*/ }
{/* 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')*/ }
{/* }*/ }
{/* >*/ }
{/* Alerts*/ }
{/* </NavLink>*/ }
{/* </li>*/ }
{/* <li>*/ }
{/* <NavLink*/ }
{/* to="/ui/buttons"*/ }
{/* 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')*/ }
{/* }*/ }
{/* >*/ }
{/* Buttons*/ }
{/* </NavLink>*/ }
{/* </li>*/ }
{/* </ul>*/ }
{/* </div>*/ }
{/* */ }{/*{/* <!-- Dropdown Menu End --> */ }
{/* </React.Fragment>*/ }
{/* );*/ }
{/* }}*/ }
{/* </SidebarLinkGroup>*/ }
{/* */ }{/*{/* <!-- Menu Item Ui Elements --> */ }
{/* */ }{/*{/* <!-- Menu Item Auth Pages --> */ }
{/* <SidebarLinkGroup*/ }
{/* activeCondition={*/ }
{/* pathname === '/auth' || pathname.includes('auth')*/ }
{/* }*/ }
{/* >*/ }
{/* {(handleClick, open) => {*/ }
{/* return (*/ }
{/* <React.Fragment>*/ }
{/* <NavLink*/ }
{/* to="#"*/ }
{/* className={`group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${*/ }
{/* (pathname === '/auth' || pathname.includes('auth')) &&*/ }
{/* 'bg-graydark dark:bg-meta-4'*/ }
{/* }`}*/ }
{/* onClick={(e) => {*/ }
{/* e.preventDefault();*/ }
{/* sidebarExpanded*/ }
{/* ? handleClick()*/ }
{/* : setSidebarExpanded(true);*/ }
{/* }}*/ }
{/* >*/ }
{/* <svg*/ }
{/* className="fill-current"*/ }
{/* width="18"*/ }
{/* height="19"*/ }
{/* viewBox="0 0 18 19"*/ }
{/* fill="none"*/ }
{/* xmlns="http://www.w3.org/2000/svg"*/ }
{/* >*/ }
{/* <g clipPath="url(#clip0_130_9814)">*/ }
{/* <path*/ }
{/* d="M12.7127 0.55835H9.53457C8.80332 0.55835 8.18457 1.1771 8.18457 1.90835V3.84897C8.18457 4.18647 8.46582 4.46772 8.80332 4.46772C9.14082 4.46772 9.45019 4.18647 9.45019 3.84897V1.88022C9.45019 1.82397 9.47832 1.79585 9.53457 1.79585H12.7127C13.3877 1.79585 13.9221 2.33022 13.9221 3.00522V15.0709C13.9221 15.7459 13.3877 16.2802 12.7127 16.2802H9.53457C9.47832 16.2802 9.45019 16.2521 9.45019 16.1959V14.2552C9.45019 13.9177 9.16894 13.6365 8.80332 13.6365C8.43769 13.6365 8.18457 13.9177 8.18457 14.2552V16.1959C8.18457 16.9271 8.80332 17.5459 9.53457 17.5459H12.7127C14.0908 17.5459 15.1877 16.4209 15.1877 15.0709V3.03335C15.1877 1.65522 14.0627 0.55835 12.7127 0.55835Z"*/ }
{/* fill=""*/ }
{/* />*/ }
{/* <path*/ }
{/* d="M10.4346 8.60205L7.62207 5.7333C7.36895 5.48018 6.97519 5.48018 6.72207 5.7333C6.46895 5.98643 6.46895 6.38018 6.72207 6.6333L8.46582 8.40518H3.45957C3.12207 8.40518 2.84082 8.68643 2.84082 9.02393C2.84082 9.36143 3.12207 9.64268 3.45957 9.64268H8.49395L6.72207 11.4427C6.46895 11.6958 6.46895 12.0896 6.72207 12.3427C6.83457 12.4552 7.00332 12.5114 7.17207 12.5114C7.34082 12.5114 7.50957 12.4552 7.62207 12.3145L10.4346 9.4458C10.6877 9.24893 10.6877 8.85518 10.4346 8.60205Z"*/ }
{/* fill=""*/ }
{/* />*/ }
{/* </g>*/ }
{/* <defs>*/ }
{/* <clipPath id="clip0_130_9814">*/ }
{/* <rect*/ }
{/* width="18"*/ }
{/* height="18"*/ }
{/* fill="white"*/ }
{/* transform="translate(0 0.052124)"*/ }
{/* />*/ }
{/* </clipPath>*/ }
{/* </defs>*/ }
{/* </svg>*/ }
{/* Authentication*/ }
{/* <svg*/ }
{/* className={`absolute right-4 top-1/2 -translate-y-1/2 fill-current ${*/ }
{/* open && 'rotate-180'*/ }
{/* }`}*/ }
{/* width="20"*/ }
{/* height="20"*/ }
{/* viewBox="0 0 20 20"*/ }
{/* fill="none"*/ }
{/* xmlns="http://www.w3.org/2000/svg"*/ }
{/* >*/ }
{/* <path*/ }
{/* fillRule="evenodd"*/ }
{/* clipRule="evenodd"*/ }
{/* d="M4.41107 6.9107C4.73651 6.58527 5.26414 6.58527 5.58958 6.9107L10.0003 11.3214L14.4111 6.91071C14.7365 6.58527 15.2641 6.58527 15.5896 6.91071C15.915 7.23614 15.915 7.76378 15.5896 8.08922L10.5896 13.0892C10.2641 13.4147 9.73651 13.4147 9.41107 13.0892L4.41107 8.08922C4.08563 7.76378 4.08563 7.23614 4.41107 6.9107Z"*/ }
{/* fill=""*/ }
{/* />*/ }
{/* </svg>*/ }
{/* </NavLink>*/ }
{/* */ }{/*{/* <!-- Dropdown Menu Start --> */ }
{/* <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="/auth/signin"*/ }
{/* 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')*/ }
{/* }*/ }
{/* >*/ }
{/* Sign In*/ }
{/* </NavLink>*/ }
{/* </li>*/ }
{/* <li>*/ }
{/* <NavLink*/ }
{/* to="/auth/signup"*/ }
{/* 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')*/ }
{/* }*/ }
{/* >*/ }
{/* Sign Up*/ }
{/* </NavLink>*/ }
{/* </li>*/ }
{/* </ul>*/ }
{/* </div>*/ }
{/* */ }{/*{/* <!-- Dropdown Menu End --> */ }
{/* </React.Fragment>*/ }
{/* );*/ }
{/* }}*/ }
{/* </SidebarLinkGroup>*/ }
{/* */ }{/*{/* <!-- Menu Item Auth Pages --> */ }
{/* </ul>*/ }
{/*</div>}*/ }
</nav>
{/* <!-- Sidebar Menu --> */ }
</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
* it under the terms of the GNU Affero General Public License as published
@ -20,11 +20,12 @@ interface SidebarLinkGroupProps
{
children: (handleClick: () => void, open: boolean) => ReactNode;
activeCondition: boolean;
isOpen: boolean;
}
const SidebarLinkGroup = ({ children, activeCondition }: SidebarLinkGroupProps) =>
const SidebarLinkGroup = ({ children, activeCondition, isOpen }: SidebarLinkGroupProps) =>
{
const [ open, setOpen ] = useState<boolean>(activeCondition);
const [ open, setOpen ] = useState<boolean>(isOpen ?? activeCondition);
const handleClick = () =>
{

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
* 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
* 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
* it under the terms of the GNU Affero General Public License as published
@ -14,25 +14,109 @@
* See LICENSE for more.
*/
import { ChangeEventHandler } from "react";
import { ChangeEventHandler, Dispatch, ReactNode, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
export const SearchWithServerSelector = ({ searchItem, handleInputChange, children, servers, server, setServer }: {
searchItem: string;
handleInputChange: ChangeEventHandler,
children?: ReactNode
servers: string[];
server: string;
setServer: Dispatch<SetStateAction<string>>
}) =>
{
const { t } = useTranslation();
return <>
<div
className="col-span-12 rounded-sm border border-stroke bg-white px-5 p-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:col-span-8">
<div className="flex flex-col gap-6 xl:flex-row items-center justify-center">
<div className="w-full xl:w-1/2 grow">
<input onChange={ handleInputChange }
value={ searchItem }
type="text" placeholder={ t("search.placeholder_with_station") }
className="w-full rounded border-[1.5px] border-stroke bg-transparent px-5 py-3 font-normal text-black outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:text-white dark:focus:border-primary"/>
</div>
{ servers?.length &&
<div className="items-center justify-center flex gap-2 flex-wrap">
{
servers.map(x =>
{
return <a
onClick={ () =>
{
setServer(x);
} }
className={ `cursor-pointer inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5 ${ server === x ? "bg-opacity-50" : "" }` }>{ x.toUpperCase() }</a>;
})
}
<a
onClick={ () =>
{
setServer(undefined!);
} }
className={ `cursor-pointer inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5 ${ !server ? "bg-opacity-50" : "" }` }>{ t("search.none") }</a>
</div> }
</div>
{ children }
</div>
</>;
};
export const Search = ({ searchItem, handleInputChange }: {
searchItem: string;
handleInputChange: ChangeEventHandler
handleInputChange: ChangeEventHandler,
}) =>
{
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">
<div className="flex justify-center items-center">
<input
className="w-full rounded border border-stroke bg-gray py-3 pl-5 pr-5 text-black focus:border-primary focus-visible:outline-none dark:border-strokedark dark:bg-meta-4 dark:text-white dark:focus:border-primary"
type="text"
onChange={ handleInputChange }
value={ searchItem }
placeholder={ t("logs.search") }
/>
return <>
<div
className="col-span-12 rounded-sm border border-stroke bg-white px-5 p-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:col-span-8">
<div className="flex flex-col gap-6 xl:flex-row items-center justify-center">
<div className="w-full xl:w-1/2 grow">
<input onChange={ handleInputChange }
value={ searchItem }
type="text" placeholder={ t("search.placeholder") }
className="w-full rounded border-[1.5px] border-stroke bg-transparent px-5 py-3 font-normal text-black outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:text-white dark:focus:border-primary"/>
</div>
</div>
</div>
</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>
</>;
};

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
* it under the terms of the GNU Affero General Public License as published
@ -17,77 +17,52 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TActiveStationPlayersData } from "../../../types/active.ts";
import { UserIcons } from "../../mini/util/UserIcons.tsx";
import { UserIcons } from "../../mini/icons/UserIcons.tsx";
import { toast } from "react-toastify";
export const ActiveStationTable = ({ stations }: { stations: TActiveStationPlayersData[] }) =>
{
const { t } = useTranslation();
const report = (data: TActiveStationPlayersData) =>
{
toast.info(t("log.toasts.report"), {
autoClose: 5000,
});
void navigator.clipboard.writeText(`;user: \`${ data.player.username }\`\n;steam: \`https://steamcommunity.com/profiles/${ data.player.id }\`\n;server: \`${ data.server.toUpperCase() }\`\n;station: \`${ data.stationName }\`\n;link: https://${ location.hostname }/profile/${data.player.id}\n\n`);
};
return (
<div
className="rounded-sm border border-stroke bg-white px-5 pt-6 pb-2.5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
<div className="flex flex-col">
<div className="grid grid-cols-3 rounded-sm bg-gray-2 dark:bg-meta-4 sm:grid-cols-4">
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("active.server") }
</h5>
<div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4">
{ stations.map((station) =>
{
return <div
key={ station.server + station.stationShort }
className="flex flex-col align-center items-center rounded-sm border border-stroke bg-stroke shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="p-4">
<img className="rounded-full" src={ station.player.avatar } alt="Player"/>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("active.user") }
</h5>
<div className="flex flex-col p-2 align-center items-center">
<h4 className="mb-3 text-xl font-semibold text-black dark:text-white">
{ station.player.username }
<UserIcons flags={ station.player.flags }/>
</h4>
<p>{ t("log.station.station", { name: station.stationName, short: station.stationShort }) }</p>
<p>{ t("log.station.server", { server: station.server.toUpperCase() }) }</p>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("active.station") }
</h5>
</div>
<div className="hidden p-2.5 text-center sm:block xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("active.actions") }
</h5>
</div>
</div>
{ stations.map((station, key) => (
<div
className={ `grid grid-cols-3 sm:grid-cols-4 ${ stations.length === (key + 1) // todo: ...
? ""
: "border-b border-stroke dark:border-strokedark"
}` }
key={ station.player.id }
<div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap mt-auto mb-2">
<Link to={ "/profile/" + (station.steam ?? station.player.id) }
className={ `inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5 ${ station.player.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ station.player.flags.includes("private") ? { pointerEvents: "none" } : undefined }>{ t("log.buttons.profile") }</Link>
<a
onClick={ () => report(station) }
className="cursor-pointer inline-flex items-center justify-center rounded-md bg-danger py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5"
>
<div className="flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-6">{ station.server.toUpperCase() }</p>
</div>
<div className="flex justify-center items-center gap-3 p-5 lg:p-5">
<p className="text-black dark:text-white sm:block break-all">
<Link to={ "/profile/" + station.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>
{ t("log.buttons.report") }
</a>
</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
* it under the terms of the GNU Affero General Public License as published
@ -17,7 +17,8 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TActiveTrainPlayersData } from "../../../types/active.ts";
import { UserIcons } from "../../mini/util/UserIcons.tsx";
import { UserIcons } from "../../mini/icons/UserIcons.tsx";
import { toast } from "react-toastify";
export const ActiveTrainTable = ({ trains }: {
trains: TActiveTrainPlayersData[],
@ -25,74 +26,48 @@ export const ActiveTrainTable = ({ trains }: {
{
const { t } = useTranslation();
const report = (data: TActiveTrainPlayersData) =>
{
toast.info(t("log.toasts.report"), {
autoClose: 5000,
});
void navigator.clipboard.writeText(`;user: \`${ data.player.username }\`\n;steam: \`https://steamcommunity.com/profiles/${ data.player.id }\`\n;server: \`${ data.server.toUpperCase() }\`\n;train: \`${ data.trainNumber }\`\n;link: https://${ location.hostname }/profile/${ data.player.id }\n\n`);
};
return (
<div
className="rounded-sm border border-stroke bg-white px-5 pt-6 pb-2.5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
<div className="flex flex-col">
<div className="grid grid-cols-3 rounded-sm bg-gray-2 dark:bg-meta-4 sm:grid-cols-4">
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("active.server") }
</h5>
<div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4">
{ trains.map((train) =>
{
return <div
key={ train.server + train.trainNumber }
className="flex flex-col align-center items-center rounded-sm border border-stroke bg-stroke shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="p-4">
<img className="rounded-full"
src={ train.player.avatar }
alt="Player"/>
</div>
<div className="flex flex-col p-2 align-center items-center">
<h4 className="mb-3 text-xl font-semibold text-black dark:text-white">
{ train.player.username }
<UserIcons flags={ train.player.flags }/>
</h4>
<p>{ t("log.train.train", { name: train.trainName, number: train.trainNumber }) }</p>
<p>{ t("log.train.server", { server: train.server.toUpperCase() }) }</p>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("active.user") }
</h5>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("active.train") }
</h5>
</div>
<div className="hidden p-2.5 text-center sm:block xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("active.actions") }
</h5>
</div>
</div>
{ trains.map((train, key) => (
<div
className={ `grid grid-cols-3 sm:grid-cols-4 ${ trains.length === (key + 1)
? ""
: "border-b border-stroke dark:border-strokedark"
}` }
key={ train.steam }
<div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap mt-auto mb-2">
<Link to={ "/profile/" + (train.steam ?? train.player.id) }
className={ `inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5 ${ train.player.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ train.player.flags.includes("private") ? { pointerEvents: "none" } : undefined }>{ t("log.buttons.profile") }</Link>
<a
onClick={ () => report(train) }
className="cursor-pointer inline-flex items-center justify-center rounded-md bg-danger py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5"
>
<div className="flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-6">{ train.server.toUpperCase() }</p>
</div>
<div className="flex items-center justify-center gap-3 p-5 lg:p-5">
<p className="text-black dark:text-white sm:block break-all">
<Link to={ "/profile/" + train.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>
{ t("log.buttons.report") }
</a>
</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,86 +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,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/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
* it under the terms of the GNU Affero General Public License as published
@ -19,7 +19,7 @@ import { TLogStationData } from "../../../types/log.ts";
import dayjs from "dayjs";
import { toast } from "react-toastify";
import { Link } from "react-router-dom";
import { UserIcons } from "../../mini/util/UserIcons.tsx";
import { UserIcons } from "../../mini/icons/UserIcons.tsx";
export const StationLog = ({ data }: { data: TLogStationData }) =>
@ -37,21 +37,23 @@ export const StationLog = ({ data }: { data: TLogStationData }) =>
toast.info(t("log.toasts.report"), {
autoClose: 5000,
});
void navigator.clipboard.writeText(`;user: \`${ data.player.username }\`\n;steam: \`${ data.player.avatar }\`\n;left: <t:${ Math.floor(data.leftDate / 1000) }>${ data.joinedDate ? `\n;joined: <t:${ Math.floor(data.joinedDate / 1000) }>` : "" }\n;station: \`${ data.stationName }\`\n;link: ${ location.href }\n\n`);
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: ${ location.href }\n\n`);
};
return <div
className="overflow-hidden rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="px-4 pt-6 text-center lg:pb-8 xl:pb-11.5">
<div
className="mx-auto w-full max-w-30 rounded-full bg-white/20 p-1 backdrop-blur sm:h-44 sm:max-w-44 sm:p-3">
<div className="relative drop-shadow-2">
<img className="rounded-full" src={ data.player.avatar } alt="profile"/>
className="mx-auto max-w-44 rounded-full">
<div className="relative">
<img className="rounded-full"
src={ data.player.avatar }
alt="Player"/>
</div>
</div>
<div className="mt-4">
<h3 className="mb-1.5 text-2xl font-semibold text-black dark:text-white">
{ data.player.username } <UserIcons flags={data.player.flags} />
{ data.player.username } <UserIcons flags={ data.player.flags }/>
</h3>
</div>
</div>
@ -85,7 +87,7 @@ export const StationLog = ({ data }: { data: TLogStationData }) =>
{ t("log.buttons.copy") }
</a>
<Link
to={"/profile/" + data.player.id}
to={ "/profile/" + data.player.id }
className={ `inline-flex items-center justify-center rounded-md bg-primary py-4 px-10 text-center font-medium text-white hover:bg-opacity-90 lg:px-8 xl:px-10 ${ data.player.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ data.player.flags.includes("private") ? { pointerEvents: "none" } : undefined }>

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
* it under the terms of the GNU Affero General Public License as published
@ -19,7 +19,7 @@ import { TLogTrainData } from "../../../types/log.ts";
import dayjs from "dayjs";
import { toast } from "react-toastify";
import { Link } from "react-router-dom";
import { UserIcons } from "../../mini/util/UserIcons.tsx";
import { UserIcons } from "../../mini/icons/UserIcons.tsx";
export const TrainLog = ({ data }: { data: TLogTrainData }) =>
@ -37,21 +37,23 @@ export const TrainLog = ({ data }: { data: TLogTrainData }) =>
toast.info(t("log.toasts.report"), {
autoClose: 5000,
});
void navigator.clipboard.writeText(`;user: \`${ data.player.username }\`\n;steam: \`${ data.player.id }\`\n;left: <t:${ Math.floor(data.leftDate / 1000) }>${ data.joinedDate ? `\n;joined: <t:${ Math.floor(data.joinedDate / 1000) }>` : "" }\n;train: \`${ data.trainNumber }\`\n;link: ${ location.href }\n\n`);
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: ${ location.href }\n\n`);
};
return <div
className="overflow-hidden rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="px-4 pt-6 text-center lg:pb-8 xl:pb-11.5">
<div
className="mx-auto w-full max-w-30 rounded-full bg-white/20 p-1 backdrop-blur sm:h-44 sm:max-w-44 sm:p-3">
<div className="relative drop-shadow-2">
<img className="rounded-full" src={ data.player.avatar } alt="profile"/>
className="mx-auto max-w-44 rounded-full">
<div className="relative">
<img className="rounded-full"
src={ data.player.avatar }
alt="Player"/>
</div>
</div>
<div className="mt-4">
<h3 className="mb-1.5 text-2xl font-semibold text-black dark:text-white">
{ data.player.username } <UserIcons flags={data.player.flags} />
{ data.player.username } <UserIcons flags={ data.player.flags }/>
</h3>
</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
* it under the terms of the GNU Affero General Public License as published
@ -18,7 +18,9 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import dayjs from "dayjs";
import { TStationRecord } from "../../../types/station.ts";
import { UserIcons } from "../../mini/util/UserIcons.tsx";
import { UserIcons } from "../../mini/icons/UserIcons.tsx";
import { toast } from "react-toastify";
// setSearchItem: Dispatch<SetStateAction<string>>
export const StationTable = ({ stations }: {
@ -27,75 +29,59 @@ export const StationTable = ({ stations }: {
{
const { t } = useTranslation();
const report = (data: TStationRecord) =>
{
toast.info(t("log.toasts.report"), {
autoClose: 5000,
});
void navigator.clipboard.writeText(`;user: \`${ data.player.username }\`\n;steam: \`https://steamcommunity.com/profiles/${ data.player.id }\`\n;server: \`${ data.server.toUpperCase() }\`\n;left: <t:${ Math.floor(data.leftDate / 1000) }>${ data.joinedDate ? `\n;joined: <t:${ Math.floor(data.joinedDate / 1000) }>` : "" }\n;station: \`${ data.stationName }\`\n;link: https://${ location.hostname }/log/${ data.id }\n\n`);
};
return (
<div
className="rounded-sm border border-stroke bg-white px-5 pt-6 pb-2.5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
<div className="flex flex-col">
<div className="grid grid-cols-3 rounded-sm bg-gray-2 dark:bg-meta-4 sm:grid-cols-4">
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.user") }
</h5>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.station") }
</h5>
</div>
<div className="hidden sm:block p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.time") }
</h5>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.actions") }
</h5>
</div>
</div>
{ stations.map((station, key) => (
<div
className={ `grid grid-cols-3 sm:grid-cols-4 ${ stations.length === (key + 1)
? ""
: "border-b border-stroke dark:border-strokedark"
}` }
key={ station.id }
<div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4">
{ stations.map((station) =>
{
return <div
key={ station.id }
className="flex flex-col align-center items-center rounded-sm border border-stroke bg-stroke shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="p-4">
<img className="rounded-full"
src={ station.player.avatar }
alt="Player"/>
</div>
<div className="flex flex-col p-2 align-center items-center">
<h4 className="mb-3 text-xl font-semibold text-black dark:text-white">
{ station.player.username }
<UserIcons flags={ station.player.flags }/>
</h4>
<p className={'break-words'}>{ t("log.station.station", { name: station.stationName, short: station.stationShort }) }</p>
<p>{ t("log.station.server", { server: station.server.toUpperCase() }) }</p>
{ station.joinedDate &&
<p>{ t("log.station.joined", { date: dayjs(station.joinedDate).format("HH:mm DD/MM/YYYY") }) }</p> }
<p>{ t("log.station.left", { date: dayjs(station.leftDate).format("HH:mm DD/MM/YYYY") }) }</p>
{ station.joinedDate &&
<p>{ t("log.station.spent", { date: dayjs.duration(station.leftDate - station.joinedDate).format("H[h] m[m]") }) }</p> }
</div>
<div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap mt-auto mb-2">
<Link to={ "/profile/" + (station.steam ?? station.player.id) }
className={ `inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5 ${ station.player.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ station.player.flags.includes("private") ? { pointerEvents: "none" } : undefined }>{ t("log.buttons.profile") }</Link>
<Link to={ "/log/" + station.id }
className={ `inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5` }>{ t("log.buttons.record") }</Link>
<a
onClick={ () => report(station) }
className="cursor-pointer inline-flex items-center justify-center rounded-md bg-danger py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5"
>
<div className="flex items-center justify-center gap-3 p-2.5 lg:p-5">
<p className="text-black dark:text-white sm:block break-all">
<Link to={ "/profile/" + (station.steam ?? station.player.id) }
className="color-orchid">{ station.username ?? station.player.username }</Link> <UserIcons flags={station.player.flags} />
</p>
</div>
<div className="flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-6 sm:block break-all">{ station.server.toUpperCase() } - { station.stationName ?? "--" }</p>
</div>
<div className="hidden sm:flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-3">{ dayjs(station.leftDate).format("HH:mm DD/MM/YYYY") }</p>
</div>
<div
className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap sm:flex-nowrap ">
<Link
to={ "/profile/" + (station.steam ?? station.player.id) }
className={ `inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5 ${ station.player.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ station.player.flags.includes("private") ? { pointerEvents: "none" } : undefined }
>
{ t("logs.profile") }
</Link>
<Link
to={ "/log/" + station.id }
className="inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5"
>
{ t("logs.record") }
</Link>
</div>
</div>
)) }
</div>
{ t("log.buttons.report") }
</a>
</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
* it under the terms of the GNU Affero General Public License as published
@ -18,7 +18,8 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TTrainRecord } from "../../../types/train.ts";
import dayjs from "dayjs";
import { UserIcons } from "../../mini/util/UserIcons.tsx";
import { UserIcons } from "../../mini/icons/UserIcons.tsx";
import { toast } from "react-toastify";
// setSearchItem: Dispatch<SetStateAction<string>>
export const TrainTable = ({ trains }: {
@ -27,93 +28,61 @@ export const TrainTable = ({ trains }: {
{
const { t } = useTranslation();
const report = (data: TTrainRecord) =>
{
toast.info(t("log.toasts.report"), {
autoClose: 5000,
});
void navigator.clipboard.writeText(`;user: \`${ data.player.username }\`\n;steam: \`https://steamcommunity.com/profiles/${ data.player.id }\`\n;server: \`${ data.server.toUpperCase() }\`\n;left: <t:${ Math.floor(data.leftDate / 1000) }>${ data.joinedDate ? `\n;joined: <t:${ Math.floor(data.joinedDate / 1000) }>` : "" }\n;train: \`${ data.trainNumber }\`\n;link: https://${ location.hostname }/log/${ data.id }\n\n`);
};
return (
<div
className="rounded-sm border border-stroke bg-white px-5 pt-6 pb-2.5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
<div className="flex flex-col">
<div className="grid grid-cols-3 rounded-sm bg-gray-2 dark:bg-meta-4 sm:grid-cols-6">
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.user") }
</h5>
<div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4">
{ trains.map((train) =>
{
return <div
key={ train.id }
className="flex flex-col align-center items-center rounded-sm border border-stroke bg-stroke shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="p-4">
<img className="rounded-full"
src={ train.player.avatar }
alt="Player"/>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.train") }
</h5>
</div>
<div className="hidden sm:block p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.points") }
</h5>
</div>
<div className="hidden sm:block p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.distance") }
</h5>
</div>
<div className="hidden sm:block p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.time") }
</h5>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("logs.actions") }
</h5>
</div>
</div>
<div className="flex flex-col p-2 align-center items-center">
<h4 className="mb-3 text-xl font-semibold text-black dark:text-white">
{ train.player.username }
<UserIcons flags={ train.player.flags }/>
</h4>
<p>{ t("log.train.train", { name: train.trainName, number: train.trainNumber }) }</p>
<p>{ t("log.train.server", { server: train.server.toUpperCase() }) }</p>
{ train.joinedDate &&
<p>{ t("log.train.joined", { date: dayjs(train.joinedDate).format("HH:mm DD/MM/YYYY") }) }</p> }
<p>{ t("log.train.left", { date: dayjs(train.leftDate).format("HH:mm DD/MM/YYYY") }) }</p>
{ train.joinedDate &&
<p>{ t("log.train.spent", { date: dayjs.duration(train.leftDate - train.joinedDate).format("H[h] m[m]") }) }</p> }
{ trains.map((train, key) => (
<div
className={ `grid grid-cols-3 sm:grid-cols-6 ${ trains.length === (key + 1)
? ""
: "border-b border-stroke dark:border-strokedark"
}` }
key={ train.id }
<p>{ t("log.train.distance", { distance: train.distance ? (train.distance / 1000).toFixed(2) : "--" }) }</p>
<p>{ t("log.train.points", { points: train.points || "--" }) }</p>
</div>
<div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap mt-auto mb-2">
<Link to={ "/profile/" + (train.steam ?? train.player.id) }
className={ `inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5 ${ train.player.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ train.player.flags.includes("private") ? { pointerEvents: "none" } : undefined }>{ t("log.buttons.profile") }</Link>
<Link to={ "/log/" + train.id }
className={ `inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5` }>{ t("log.buttons.record") }</Link>
<a
onClick={ () => report(train) }
className="cursor-pointer inline-flex items-center justify-center rounded-md bg-danger py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5"
>
<div className="flex items-center justify-center gap-3 p-2.5 lg:p-5">
<p className="text-black dark:text-white sm:block break-all">
<Link to={ "/profile/" + (train.steam ?? train.player.id) }
className="color-orchid">{ train.username ?? train.player.username }</Link> <UserIcons flags={train.player.flags} />
</p>
</div>
<div className="flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-6 sm:block break-all">{ train.server.toUpperCase() } - { train.trainNumber ?? "--" }</p>
</div>
<div className="hidden sm:flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-6">{ train.distance ? train.points : "--" }</p>
</div>
<div className="hidden sm:flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-5">{ train.distance ? `${ (train.distance / 1000).toFixed(2) }km` : "--" }</p>
</div>
<div className="hidden sm:flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-3">{ dayjs(train.leftDate).format("HH:mm DD/MM/YYYY") }</p>
</div>
<div
className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap sm:flex-nowrap ">
<Link
to={ "/profile/" + (train.steam ?? train.player.id) }
className={ `inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5 ${ train.player.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ train.player.flags.includes("private") ? { pointerEvents: "none" } : undefined }
>
{ t("logs.profile") }
</Link>
<Link
to={ "/log/" + train.id }
className="inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5"
>
{ t("logs.record") }
</Link>
</div>
</div>
)) }
</div>
{ t("log.buttons.report") }
</a>
</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
* it under the terms of the GNU Affero General Public License as published
@ -24,7 +24,7 @@ import { ConfirmModal } from "../../mini/modal/ConfirmModal.tsx";
import { post } from "../../../util/fetcher.ts";
import { toast } from "react-toastify";
import dayjs from "dayjs";
import { UserIcons } from "../../mini/util/UserIcons.tsx";
import { UserIcons } from "../../mini/icons/UserIcons.tsx";
export const ProfileCard = ({ data }: { data: TProfileData }) =>
{
@ -61,11 +61,24 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
});
};
const adminForceUpdate = () =>
{
post(`/admin/profile/${ data.player.id }/forceUpdate`, {}, { "X-Auth-Token": token })
.then((response) =>
{
if (response.code === 200)
{
toast.success(t("admin.update.alert"));
}
});
};
const { t } = useTranslation();
return <>
<ConfirmModal showModal={ hideLeaderboardStatsModal } setShowModal={ setHideLeaderboardStatsModal }
onConfirm={ adminToggleHideLeaderboardPlayerProfile } title={ t("admin.hideLeaderboard.modal.title") }
onConfirm={ adminToggleHideLeaderboardPlayerProfile }
title={ t("admin.hideLeaderboard.modal.title") }
description={ t("admin.hideLeaderboard.modal.description") }/>
<ConfirmModal showModal={ hideProfileModal } setShowModal={ setHideProfileModal }
onConfirm={ adminHidePlayerProfile } title={ t("admin.hide.modal.title") }
@ -74,13 +87,15 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
className="overflow-hidden rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="px-4 pt-6 text-center lg:pb-8 xl:pb-11.5">
<div
className="mx-auto w-full max-w-30 rounded-full bg-white/20 p-1 backdrop-blur sm:h-44 sm:max-w-44 sm:p-3">
<div className="relative drop-shadow-2">
className="mx-auto max-w-44 rounded-full">
<div className="relative rounded-full">
<img className="rounded-full" src={ data.player.avatar } alt="profile"/>
{ data.active &&
<span className="absolute w-full rounded-full border-white bg-[#219653] dark:border-black max-w-5.5 right-0 top-0 h-5.5 border-[3px]"></span> }
</div>
</div>
<div className="mt-4">
<h3 className="mb-1.5 text-2xl font-semibold text-black dark:text-white">
<h3 className="text-2xl font-semibold text-black dark:text-white">
{ data.player.username } <UserIcons flags={ data.player.flags }/>
</h3>
@ -102,8 +117,19 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
</div>
</div>
</div>
{ data.active && data.active.type === "train" &&
<div className="mx-auto text-center">
<h4 className="font-semibold text-black dark:text-white">{ t("profile.active.train", { train: `${ data.active.trainName } - ${ data.active.trainNumber }`, server: data.active.server.toUpperCase() }) }</h4>
</div> }
{ data.active && data.active.type === "station" &&
<div className="mx-auto text-center">
<h4 className="font-semibold text-black dark:text-white">{ t("profile.active.station", { station: `${ data.active.stationName } - ${ data.active.stationShort }`, server: data.active.server.toUpperCase() }) }</h4>
</div> }
</div>
{ Object.keys(data.player.trainStats || {}).length > 0 &&
<div className="bg-white px-5 pt-6 pb-5 shadow-default dark:bg-boxdark sm:px-7.5">
<div className="group relative cursor-pointer" onClick={ () => setShowTrains(val => !val) }>
@ -113,7 +139,7 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
{ showTrains &&
<div className="flex flex-col rounded-sm border border-stroke dark:border-strokedark">
<div className="grid grid-cols-3 rounded-sm bg-gray-2 dark:bg-meta-4 sm:grid-cols-4">
<div className="grid grid-cols-3 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("profile.trains.train") }
@ -133,13 +159,13 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
</h5>
<FlexArrowIcon rotated={ sortTrainsBy === "score" }/>
</div>
<div className="hidden sm:flex flex-row align-center justify-center gap-2 p-2.5 text-center xl:p-5 cursor-pointer"
onClick={ () => setSortTrainsBy("time") }>
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("profile.trains.time") }
</h5>
<FlexArrowIcon rotated={ sortTrainsBy === "time" }/>
</div>
{/*<div className="hidden sm:flex flex-row align-center justify-center gap-2 p-2.5 text-center xl:p-5 cursor-pointer"*/ }
{/* onClick={ () => setSortTrainsBy("time") }>*/ }
{/* <h5 className="text-sm font-medium uppercase xsm:text-base">*/ }
{/* { t("profile.trains.time") }*/ }
{/* </h5>*/ }
{/* <FlexArrowIcon rotated={ sortTrainsBy === "time" }/>*/ }
{/*</div>*/ }
</div>
{ Object.keys(data.player.trainStats).sort((a, b) => data.player.trainStats[ b ][ sortTrainsBy ] - data.player.trainStats[ a ][ sortTrainsBy ]).map(trainName =>
@ -147,7 +173,7 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
const train = data.player.trainStats[ trainName ];
return <div
className={ `grid grid-cols-3 sm:grid-cols-4 border-t border-t-stroke dark:border-t-strokedark` }
className={ `grid grid-cols-3 sm:grid-cols-3 border-t border-t-stroke dark:border-t-strokedark` }
key={ trainName }
>
<div className="flex items-center justify-center gap-3 p-2.5 lg:p-5">
@ -164,9 +190,9 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
<p className="text-meta-3">{ train.score }</p>
</div>
<div className="hidden sm:flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-3">{ formatTime(train.time) }</p>
</div>
{/*<div className="hidden sm:flex items-center justify-center p-2.5 lg:p-5">*/ }
{/* <p className="text-meta-3">{ formatTime(train.time) }</p>*/ }
{/*</div>*/ }
</div>;
}) }
@ -239,6 +265,11 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
onClick={ () => setHideProfileModal(true) }>
{ t("admin.hide.button") }
</button>
<button 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"
onClick={ () => adminForceUpdate() }>
{ t("admin.update.button") }
</button>
</div>
</div>
</> }

View File

@ -0,0 +1,53 @@
/*
* 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 { UserIcons } from "../../mini/icons/UserIcons.tsx";
import { TProfilePlayer } from "../../../types/profile.ts";
// import { formatTime } from "../../../util/time.ts";
export const ProfilesTable = ({ profiles }: { profiles: TProfilePlayer[] }) =>
{
const { t } = useTranslation();
return (
<div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4">
{ profiles.map((player) =>
{
return <div
className="flex flex-col align-center items-center rounded-sm border border-stroke shadow-default dark:border-strokedark dark:bg-boxdark bg-stroke ">
<div className="p-4">
<img className="rounded-full" src={ player.avatar } alt="Player"/>
</div>
<div className="flex flex-col p-2 align-center items-center">
<h4 className="mb-3 text-xl font-semibold text-black dark:text-white">
{ player.username }
<UserIcons flags={ player.flags }/>
</h4>
</div>
<div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap sm:flex-nowrap mt-auto mb-2">
<Link to={ "/profile/" + player.id }
className={ `inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5 ${ player.flags.includes("private") ? "bg-opacity-50" : "" }` }
style={ player.flags.includes("private") ? { pointerEvents: "none" } : undefined }>{ t("log.buttons.profile") }</Link>
</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
* 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
* 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;
}
.custom-input-date::-webkit-calendar-picker-indicator {
background-position: center;
background-repeat: no-repeat;
background-size: 20px;
}
.custom-input-date-1::-webkit-calendar-picker-indicator {
background-image: url(./images/icon/icon-calendar.svg);
}
.custom-input-date-2::-webkit-calendar-picker-indicator {
background-image: url(./images/icon/icon-arrow-down.svg);
}
[x-cloak] {
display: none !important;
}

View File

@ -1,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 useSWR from "swr";
import { get } from "../util/fetcher.ts";
@ -5,27 +22,32 @@ import useLocalStorage from "./useLocalStorage.tsx";
export type AdminContext = { isAdmin: boolean; username: string; token: string; };
const defaultValue: AdminContext = { isAdmin: false, username: '', token: '' };
const defaultValue: AdminContext = { isAdmin: false, username: "", token: "" };
const AuthContext = createContext<AdminContext>(defaultValue);
// {"code":200,"status":true,"data":{"isAdmin":true,"username":"alekswilc","token":"test"}}
const getUserAuthData = () => {
const [value, _setValue] = useLocalStorage<string|undefined>('auth_token', undefined);
const getUserAuthData = () =>
{
const [ value, _setValue ] = useLocalStorage<string | undefined>("auth_token", undefined);
if (!value || value === 'undefined')
return { isAdmin: false, username: '', token: '' };
if (!value || value === "undefined")
{
return { isAdmin: false, username: "", token: "" };
}
const { data } = useSWR(`/admin/auth/?token=${value}`, get);
const { data } = useSWR(`/admin/auth/?token=${ value }`, get);
return data ? { isAdmin: data.data.isAdmin, username: data.data.username, token: value } : { isAdmin: false, username: '', token: '' };
}
export const AuthProvider = ({ children }: { children: ReactNode }) => {
return <AuthContext.Provider value={getUserAuthData()}>{children}</AuthContext.Provider>;
return data ? { isAdmin: data.data.isAdmin, username: data.data.username, token: value } : { isAdmin: false, username: "", token: "" };
};
export const useAuth = () => {
export const AuthProvider = ({ children }: { children: ReactNode }) =>
{
return <AuthContext.Provider value={ getUserAuthData() }>{ children }</AuthContext.Provider>;
};
export const useAuth = () =>
{
return useContext(AuthContext);
};

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
* 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
* it under the terms of the GNU Affero General Public License as published
@ -28,12 +28,14 @@ function useLocalStorage<T>(
try
{
const item = window.localStorage.getItem(key);
if (item) {
try {
if (item)
{
try
{
return item ? JSON.parse(item) : initialValue;
}
catch {
return item ? item : initialValue;
} catch
{
return (item && item !== "undefined") ? item : initialValue;
}
}
} 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
* 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 translationsInEng from "./languages/en.json";
import translationsInPl from "./languages/pl.json";
import translationsInCs from "./languages/cs.json";
const resources = {
en: {
@ -27,6 +28,9 @@ const resources = {
pl: {
translation: translationsInPl,
},
cs: {
translation: translationsInCs,
}
};
void i18n
@ -35,10 +39,11 @@ void i18n
.init({
resources,
debug: false,
fallbackLng: {
"pl-PL": [ "pl" ],
fallbackLng: (code: string) => {
if (code.includes('pl')) return 'pl'; // Polish
if (resources as any['cs'] && code.includes('cs')) return 'cs'; // Czech
default: [ "en" ],
return 'en'; // English
},
interpolation: {
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,13 +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": {
"placeholder": "ex. alekswilc",
"placeholder_with_station": "ex. alekswilc,Katowice",
"none": "None"
},
"home": {
"stats": {
@ -19,7 +14,8 @@
"description": "Simrail Stats - The best SimRail logs and statistics site!",
"buttons": {
"project": "Project page",
"forum": "Forum Page"
"forum": "Forum page",
"discord": "Discord server"
},
"footer": {
"license": "License:",
@ -34,32 +30,18 @@
"button": "Return to homepage."
},
"leaderboard": {
"user": "Player",
"time": "Time",
"distance": "Distance",
"points": "Points",
"profile": "Profile",
"actions": "Actions"
},
"active": {
"server": "Server",
"user": "Player",
"train": "Train",
"station": "Station",
"profile": "Profile",
"actions": "Actions"
},
"logs": {
"user": "Player",
"time": "Time",
"distance": "Distance",
"points": "Points",
"profile": "Profile",
"record": "Record",
"train": "Train",
"actions": "Actions",
"search": "Type to search",
"station": "Station"
"train": {
"distance": "Driven distance: {{distance}}km",
"points": "Earned points: {{points}}"
},
"station": {
"time": "Time spent as a dispatcher: {{time}}"
},
"buttons": {
"trainDistance": "Longest distance traveled",
"trainPoints": "Most points",
"dispatcherTime": "Most hours as dispatcher"
}
},
"content_loader": {
"error": {
@ -100,7 +82,11 @@
"description": "The player's profile could not be displayed due to active moderator actions."
}
},
"info": "Note: This user's statistics are collected since {{date}}."
"info": "Note: This user's statistics are collected since {{date}}.",
"active": {
"train": "Drives the train {{train}}, on the server {{server}}",
"station": "Active on station {{station}}, on server {{server}}"
}
},
"log": {
"errors": {
@ -133,12 +119,13 @@
},
"toasts": {
"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": {
"report": "Report",
"copy": "Copy link",
"profile": "Profile"
"profile": "Profile",
"record": "Record"
}
},
"sidebar": {
@ -151,10 +138,16 @@
"info": "INFO",
"admin": "ADMIN",
"logged": "Logged as {{username}}",
"logout": "Log out"
"logout": "Log out",
"profiles": "Players"
},
"icons": {
"admin": "Administrator",
"leaderboard_hidden": "Leaderboard hidden",
"hidden": "Profile hidden"
},
"admin": {
"header": "Akcje moderacyjne",
"header": "Moderator actions",
"hideLeaderboard": {
"modal": {
"title": "Are you sure?",
@ -171,6 +164,10 @@
},
"button": "Hide profile",
"alert": "Player profile hidden."
},
"update": {
"button": "Force update",
"alert": "Profile updated!"
}
}
}

View File

@ -1,13 +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": {
"placeholder": "np. alekswilc",
"placeholder_with_station": "np. alekswilc,Katowice",
"none": "Brak"
},
"home": {
"stats": {
@ -19,7 +14,8 @@
"description": "Najbardziej rozbudowana strona z logami i statystykami gry SimRail!",
"buttons": {
"project": "Strona projektu",
"forum": "Strona na forum"
"forum": "Strona na forum",
"discord": "Serwer discord"
},
"footer": {
"license": "Licencja:",
@ -34,32 +30,18 @@
"button": "Wróć do strony głównej"
},
"leaderboard": {
"user": "Gracz",
"time": "Czas",
"distance": "Dystans",
"points": "Punkty",
"profile": "Profil",
"actions": "Akcje"
},
"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",
"search": "Wpisz, aby wyszukać",
"station": "Stacja"
"train": {
"distance": "Przejechany dystans: {{distance}}km",
"points": "Zdobyte punkty: {{points}}"
},
"station": {
"time": "Spędzony czas jako dyżurny ruchu: {{time}}"
},
"buttons": {
"trainDistance": "Największy przejechany dystans",
"trainPoints": "Najwięcej punktów",
"dispatcherTime": "Najwięcej godzin na nastawni"
}
},
"content_loader": {
"error": {
@ -100,7 +82,11 @@
"description": "Profil gracza nie mógł zostać wyświetlony ze względu na aktywne działania moderatora."
}
},
"info": "Uwaga: Statystyki tego użytkownika gromadzone są od {{date}} r."
"info": "Uwaga: Statystyki tego użytkownika gromadzone są od {{date}} r.",
"active": {
"train": "Prowadzi pociąg {{train}}, na serwerze {{server}}",
"station": "Aktywny na stacji {{station}}, na serwerze {{server}}"
}
},
"log": {
"errors": {
@ -133,12 +119,13 @@
},
"toasts": {
"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": {
"report": "Zgłoś",
"copy": "Kopiuj link",
"profile": "Profil"
"profile": "Profil",
"record": "Rekord"
}
},
"sidebar": {
@ -151,10 +138,16 @@
"info": "INFO",
"admin": "ADMIN",
"logged": "Zalogowano jako {{username}}",
"logout": "Wyloguj"
"logout": "Wyloguj",
"profiles": "Gracze"
},
"icons": {
"admin": "Administrator",
"leaderboard_hidden": "Tablica wyników ukryta",
"hidden": "Profil ukryty"
},
"admin": {
"header": "Moderator actions",
"header": "Akcje moderacyjne",
"hideLeaderboard": {
"modal": {
"title": "Czy jesteś pewien?",
@ -171,6 +164,10 @@
},
"button": "Ukryj profil",
"alert": "Ukryto profil gracza."
},
"update": {
"button": "Wymuś aktualizacje",
"alert": "Zaktualizowano profil!"
}
}
}

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
* 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
* 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
* 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/satoshi.css";
import "flatpickr/dist/flatpickr.min.css";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime.js";
import duration from "dayjs/plugin/duration.js";
dayjs.extend(duration);
dayjs.extend(relativeTime);

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 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
* it under the terms of the GNU Affero General Public License as published
@ -19,7 +19,7 @@ import { Link, useSearchParams } from "react-router-dom";
import { TStatsResponse } from "../types/stats.ts";
import { CardDataStats } from "../components/mini/util/CardDataStats.tsx";
import { get } from "../util/fetcher.ts";
import useSWR from 'swr';
import useSWR from "swr";
import { LoadError } from "../components/mini/loaders/ContentLoader.tsx";
export const Home = () =>
@ -28,10 +28,11 @@ export const Home = () =>
const { data, error } = useSWR<TStatsResponse>("/stats/", get, { refreshInterval: 10_000, errorRetryCount: 5 });
const [searchParams, setSearchParams] = useSearchParams();
const [ searchParams, setSearchParams ] = useSearchParams();
if (searchParams.get('admin_token')) {
window.localStorage.setItem('auth_token', searchParams.get('admin_token')!);
if (searchParams.get("admin_token"))
{
window.localStorage.setItem("auth_token", searchParams.get("admin_token")!);
setSearchParams(new URLSearchParams());
setTimeout(() => window.location.reload(), 1000);
}
@ -39,13 +40,14 @@ export const Home = () =>
return (
<>
<div className="flex pb-5">
{ error && <LoadError /> }
{ error && <LoadError/> }
</div>
<div className="flex flex-col gap-10">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-6 xl:grid-cols-3 2xl:gap-7.5">
<CardDataStats title={ t("home.stats.trains") } total={ data?.data?.stats?.trains ?? "-" }/>
<CardDataStats title={ t("home.stats.dispatchers") } total={ data?.data?.stats?.dispatchers ?? "-" }/>
<CardDataStats title={ t("home.stats.dispatchers") }
total={ data?.data?.stats?.dispatchers ?? "-" }/>
<CardDataStats title={ t("home.stats.profiles") } total={ data?.data?.stats?.profiles ?? "-" }/>
</div>
@ -105,7 +107,7 @@ export const Home = () =>
</p>
<p>{ data?.data?.git?.version && <Link className="color-orchid"
to={ `https://git.alekswilc.dev/simrail/simrail.pro/releases/tag/${ data?.data?.git?.version }` }>{ data?.data?.git?.version }</Link> }{ data?.data?.git?.version && data?.data?.git?.commit && " | " }{ data?.data?.git?.commit &&
to={ `https://git.alekswilc.dev/simrail/simrail.pro/releases/tag/${ data?.data?.git?.version }` }>{ data?.data?.git?.version }</Link> }{ data?.data?.git?.version && data?.data?.git?.commit && " | " }{ data?.data?.git?.commit &&
<Link className="color-orchid"
to={ `https://git.alekswilc.dev/simrail/simrail.pro/commit/${ data?.data?.git?.commit }` }>{ data?.data?.git?.commit }</Link> }</p>

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
* it under the terms of the GNU Affero General Public License as published
@ -17,7 +17,7 @@
import { ChangeEvent, useEffect, useState } from "react";
import { useDebounce } from "use-debounce";
import { Search } from "../../components/mini/util/Search.tsx";
import { SearchWithServerSelector } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import { get } from "../../util/fetcher.ts";
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 [ 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);
useEffect(() =>
{
searchValue === "" ? searchParams.delete("q") : searchParams.set("q", searchValue);
const params = new URLSearchParams();
searchValue && params.set("q", searchValue);
searchValue && params.set("query", searchValue);
server && params.set("server", server);
setSearchParams(params.toString());
setParams(params);
}, [ searchValue ]);
}, [ searchValue, server ]);
useEffect(() =>
{
setSearchItem(searchParams.get("q") ?? "");
}, [ searchParams ]);
setSearchItem(searchParams.get("query") ?? "");
setServer(searchParams.get("server") ?? "");
}, []);
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/> }
<SearchWithServerSelector handleInputChange={ handleInputChange } searchItem={ searchItem }
servers={ data?.code === 200 ? data?.data?.servers : [] }
server={ server } setServer={ setServer }/>
{ error && <LoadError/> }
{ isLoading && <ContentLoader/> }
{ isLoading && <ContentLoader/> }
{ data && (data && data.code === 404) || (data && !data?.data?.records?.length) &&
<WarningAlert title={ t("content_loader.notfound.header") }
description={ t("content_loader.notfound.description") }/> }
{ data && (data && data.code === 404) || (data && !data?.data?.records?.length) &&
<WarningAlert title={ t("content_loader.notfound.header") }
description={ t("content_loader.notfound.description") }/> }
{ data && data.code === 200 && data.data && !!data?.data?.records?.length &&
<ActiveStationTable stations={ data.data.records }/> }
{ data && data.code === 200 && data.data && !!data?.data?.records?.length &&
<ActiveStationTable stations={ data.data.records }/> }
</>
</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
* it under the terms of the GNU Affero General Public License as published
@ -17,7 +17,7 @@
import { ChangeEvent, useEffect, useState } from "react";
import { useDebounce } from "use-debounce";
import { Search } from "../../components/mini/util/Search.tsx";
import { SearchWithServerSelector } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { get } from "../../util/fetcher.ts";
@ -32,27 +32,27 @@ export const ActiveTrainPlayers = () =>
const { data, error, isLoading } = useSWR(`/active/train/?${ params.toString() }`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchParams, setSearchParams ] = useSearchParams();
const [ searchItem, setSearchItem ] = useState(searchParams.get("q") ?? "");
const [ searchItem, setSearchItem ] = useState(searchParams.get("query") ?? "");
const [ server, setServer ] = useState(searchParams.get("server") ?? "");
const [ searchValue ] = useDebounce(searchItem, 500);
useEffect(() =>
{
searchValue === "" ? searchParams.delete("q") : searchParams.set("q", searchValue);
const params = new URLSearchParams();
searchValue && params.set("q", searchValue);
searchValue && params.set("query", searchValue);
server && params.set("server", server);
setSearchParams(params.toString());
setParams(params);
}, [ searchValue ]);
}, [ searchValue, server ]);
useEffect(() =>
{
setSearchItem(searchParams.get("q") ?? "");
}, [ searchParams ]);
setSearchItem(searchParams.get("query") ?? "");
setServer(searchParams.get("server") ?? "");
}, []);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) =>
{
@ -64,17 +64,21 @@ export const ActiveTrainPlayers = () =>
return (
<>
<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/> }
{ 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 === 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 && <ActiveTrainTable trains={ data?.data?.records } /> }
{ data && data.code === 200 && !!data?.data?.records?.length &&
<ActiveTrainTable trains={ data?.data?.records }/> }
</>
</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
* it under the terms of the GNU Affero General Public License as published
@ -24,9 +24,7 @@ export const NotFoundError = () =>
return (
<>
<div className="flex flex-col gap-10">
<div
className="overflow-hidden rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="px-4 pb-6 text-center">

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,82 +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 { StationTable } from "../../components/pages/leaderboard/StationTable.tsx";
import { useDebounce } from "use-debounce";
import { Search } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import { 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 && <StationTable stations={ data.data.records } /> }
</>
</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
* it under the terms of the GNU Affero General Public License as published
@ -33,8 +33,8 @@ export const Log = () =>
return (
<>
{/* ERROR */}
{ error && <LoadError /> }
{/* ERROR */ }
{ error && <LoadError/> }
{/* LOADING */ }
{ isLoading && <ContentLoader/> }
{/* NOT FOUND */ }

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
* it under the terms of the GNU Affero General Public License as published
@ -17,39 +17,43 @@
import { ChangeEvent, useEffect, useState } from "react";
import { StationTable } from "../../components/pages/logs/StationTable.tsx";
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 useSWR from 'swr';
import useSWR from "swr";
import { get } from "../../util/fetcher.ts";
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
import { useTranslation } from 'react-i18next';
import { useTranslation } from "react-i18next";
import { Paginator } from "../../components/mini/util/Paginator.tsx";
export const StationLogs = () =>
{
const [params, setParams] = useState(new URLSearchParams());
const { data, error, isLoading } = useSWR(`/stations/?${params.toString()}`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ params, setParams ] = useState(new URLSearchParams());
const { data, error, isLoading } = useSWR(`/stations/?${ params.toString() }`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
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);
useEffect(() =>
{
searchValue === "" ? searchParams.delete("q") : searchParams.set("q", searchValue);
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());
setParams(params);
}, [ searchValue ]);
}, [ searchValue, server, page ]);
useEffect(() =>
{
setSearchItem(searchParams.get("q") ?? "");
}, [ searchParams ]);
setSearchItem(searchParams.get("query") ?? "");
setServer(searchParams.get("server") ?? "");
setPage(parseInt(searchParams.get("page") as string) || 1);
}, []);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) =>
{
@ -61,16 +65,25 @@ export const StationLogs = () =>
return (
<>
<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/> }
{isLoading && <ContentLoader/> }
{ isLoading && <ContentLoader/> }
{ (data && data.code === 404) || (data && data.code === 200 && !data?.data?.records?.length) && <WarningAlert title={ t("content_loader.notfound.header") }
description={ t("content_loader.notfound.description") }/> }
{ (data && data.code === 404) || (data && data.code === 200 && !data?.data?.records?.length) &&
<WarningAlert title={ t("content_loader.notfound.header") }
description={ t("content_loader.notfound.description") }/> }
{data && data.code === 200 && !!data?.data?.records?.length && <StationTable stations={data.data.records} /> }
{ data && data.code === 200 && !!data?.data?.records?.length &&
<>
<StationTable stations={ data.data.records }/>
<Paginator page={ page } pages={ data.data.pages } setPage={ setPage }/>
</>
}
</>
</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
* it under the terms of the GNU Affero General Public License as published
@ -17,13 +17,14 @@
import { ChangeEvent, useEffect, useState } from "react";
import { TrainTable } from "../../components/pages/logs/TrainTable.tsx";
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 { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
import { useTranslation } from "react-i18next";
import { get } from "../../util/fetcher.ts";
import useSWR from "swr";
import { Paginator } from "../../components/mini/util/Paginator.tsx";
export const TrainLogs = () =>
{
@ -31,25 +32,27 @@ export const TrainLogs = () =>
const { data, error, isLoading } = useSWR(`/trains/?${ params.toString() }`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
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);
useEffect(() =>
{
searchValue === "" ? searchParams.delete("q") : searchParams.set("q", searchValue);
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());
setParams(params);
}, [ searchValue ]);
}, [ searchValue, server, page ]);
useEffect(() =>
{
setSearchItem(searchParams.get("q") ?? "");
}, [ searchParams ]);
setSearchItem(searchParams.get("query") ?? "");
setServer(searchParams.get("server") ?? "");
}, []);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) =>
{
@ -61,16 +64,24 @@ export const TrainLogs = () =>
return (
<>
<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/> }
{ isLoading && <ContentLoader/> }
{ (data && data.code === 404) || (data && data.code === 200 && !data?.data?.records?.length) && <WarningAlert title={ t("content_loader.notfound.header") }
description={ t("content_loader.notfound.description") }/> }
{ (data && data.code === 404) || (data && data.code === 200 && !data?.data?.records?.length) &&
<WarningAlert title={ t("content_loader.notfound.header") }
description={ t("content_loader.notfound.description") }/> }
{ data && data.code === 200 && !!data?.data?.records?.length && <TrainTable trains={ data.data.records }/> }
{ data && data.code === 200 && !!data?.data?.records?.length &&
<>
<TrainTable trains={ data.data.records }/>
<Paginator page={ page } pages={ data.data.pages } setPage={ setPage }/>
</> }
</>
</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
* it under the terms of the GNU Affero General Public License as published
@ -19,18 +19,18 @@ import { useParams } from "react-router-dom";
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
import { ProfileCard } from "../../components/pages/profile/Profile.tsx";
import { ProfileCard } from "../../components/pages/profiles/Profile.tsx";
import { useTranslation } from "react-i18next";
import { PageMeta } from "../../components/mini/util/PageMeta.tsx";
import { formatTime } from "../../util/time.ts";
import useSWR from 'swr';
import useSWR from "swr";
import { get } from "../../util/fetcher.ts";
export const Profile = () =>
{
const { id } = useParams();
const { data, error, isLoading } = useSWR(`/profiles/${ id }`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
const { data, error, isLoading } = useSWR(`/profiles/${ id }`, get, { refreshInterval: 5_000, errorRetryCount: 5 });
const { t } = useTranslation();
@ -38,8 +38,8 @@ export const Profile = () =>
<>
{/* LOADING */ }
{ isLoading && <ContentLoader/> }
{/* ERROR */}
{ error && <LoadError /> }
{/* ERROR */ }
{ error && <LoadError/> }
{/* BLACKLISTED */ }
{ data && data.code === 403 && <PageMeta title="simrail.pro | Profile hidden"
description="The player's profile could not be displayed due to active moderator actions."/> }
@ -47,14 +47,14 @@ export const Profile = () =>
description={ t("profile.errors.blacklist.description") }/> }
{/* NOT FOUND */ }
{ data && data.code === 404 && <PageMeta title="simrail.pro | Profile not found"
description="Player's profile could not be found or the player has a private Steam profile."/> }
description="Player's profile could not be found or the player has a private Steam profile."/> }
{ data && data.code === 404 && <WarningAlert title={ t("profile.errors.notfound.title") }
description={ t("profile.errors.notfound.description") }/> }
description={ t("profile.errors.notfound.description") }/> }
{/* SUCCESS */ }
{ data && data.code === 200 && <PageMeta image={ data.data.player.username }
title={ `simrail.pro | ${ data.data.player.username }'s profile` }
description={ `${ data.data.player.trainDistance ? 0 : ((data.data.player.trainDistance / 1000).toFixed(2)) } driving experience |
title={ `simrail.pro | ${ data.data.player.username }'s profile` }
description={ `${ data.data.player.trainDistance ? 0 : ((data.data.player.trainDistance / 1000).toFixed(2)) } driving experience |
${ data.data.player.dispatcherTime ? 0 : formatTime(data.data.player.dispatcherTime) } dispatcher experience` }/> }
{ data && data.code === 200 && <ProfileCard data={ data.data }/> }
</>

View File

@ -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
* it under the terms of the GNU Affero General Public License as published
@ -14,28 +14,26 @@
* See LICENSE for more.
*/
import { ChangeEvent, useEffect, useState } from "react";
import { TrainTable } from "../../components/pages/leaderboard/TrainTable.tsx";
import { useDebounce } from "use-debounce";
import { Search } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
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";
import { ProfilesTable } from "../../components/pages/profiles/ProfilesTable.tsx";
import { Paginator } from "../../components/mini/util/Paginator.tsx";
export const TrainLeaderboard = () =>
export const Profiles = () =>
{
const [ params, setParams ] = useState(new URLSearchParams());
const { data, error, isLoading } = useSWR(`/leaderboard/train/?${ params.toString() }`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
const { data, error, isLoading } = useSWR(`/profiles/?${ 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 [ page, setPage ] = useState(parseInt(searchParams.get("page") as string) || 1);
const [ searchValue ] = useDebounce(searchItem, 500);
useEffect(() =>
@ -44,17 +42,16 @@ export const TrainLeaderboard = () =>
const params = new URLSearchParams();
searchValue && params.set("q", searchValue);
sortBy && params.set("s", sortBy);
page && params.set("page", page.toString());
setSearchParams(params.toString());
setParams(params);
}, [ searchValue, sortBy ]);
}, [ searchValue, page ]);
useEffect(() =>
{
setSearchItem(searchParams.get("q") ?? "");
setSortBy(searchParams.get("s") ?? "distance");
setPage(parseInt(searchParams.get("page") as string) || 1);
}, [ searchParams ]);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) =>
@ -73,11 +70,16 @@ export const TrainLeaderboard = () =>
{ 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 === 404) || (data && data.code === 200 && !data?.data?.records?.length) &&
<WarningAlert title={ t("content_loader.notfound.header") }
description={ t("content_loader.notfound.description") }/> }
{ data && data.code === 200 && !!data?.data?.records?.length && <TrainTable trains={ data?.data?.records } setSortBy={ setSortBy } sortBy={ sortBy }/> }
{ data && data.code === 200 && !!data?.data?.records?.length &&
<>
<ProfilesTable profiles={ data.data.records }/>
<Paginator page={ page } pages={ data.data.pages } setPage={ setPage }/>
</>
}
</>
</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
* it under the terms of the GNU Affero General Public License as published

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