feat: rewrite profile section, some minor chaanges #120

Merged
alekswilc merged 2 commits from new-profiles into main 2025-04-21 20:47:12 +02:00
10 changed files with 374 additions and 269 deletions

View File

@ -6,7 +6,7 @@
"scripts": {
"build": "docker build --progress=plain -t simrailpro:backend .",
"rawbuild": "yarn tsc",
"start": "yarn build && doppler run node ../../dist/backend/index.js"
"start": "yarn rawbuild && node --env-file=.env dist/index.js"
},
"author": "Aleksander <alekswilc> Wilczyński",
"license": "AGPL-3.0-only",

View File

@ -24,6 +24,7 @@ import { StatsRoute } from "./routes/stats.js";
import { LogRoute } from "./routes/log.js";
import { ActivePlayersRoute } from "./routes/activePlayer.js";
import { AdminRoute } from "./routes/admin.js";
import { ImagesRoute } from './routes/images.js';
export class ApiModule
{
@ -39,6 +40,8 @@ export class ApiModule
router.use("/leaderboard/", LeaderboardRoute.load());
router.use("/active/", ActivePlayersRoute.load());
router.use("/admin/", AdminRoute.load());
router.use("/images/", ImagesRoute.load());
router.use("/stats/", StatsRoute.load());
router.use("/log/", LogRoute.load());

View File

@ -137,10 +137,80 @@ export const trainsList = [
"4E/EU07-*",
],
},
{
train: 'Ty2',
pattern: [
'Ty2/*'
]
}
];
export const stationsMap: Record<string, string> = {
"Grodzisk Mazowiecki": "https://api.simrail.eu:8083/Thumbnails/Stations/gr1m.jpg",
"Korytów": "https://api.simrail.eu:8083/Thumbnails/Stations/kr1m.jpg",
"Szeligi": "https://api.simrail.eu:8083/Thumbnails/Stations/sz1m.jpg",
"Włoszczowa Północ": "https://api.simrail.eu:8083/Thumbnails/Stations/wp1m.jpg",
"Knapówka": "https://api.simrail.eu:8083/Thumbnails/Stations/kn1m.jpg",
"Psary": "https://api.simrail.eu:8083/Thumbnails/Stations/ps1m.jpg",
"Góra Włodowska": "https://api.simrail.eu:8083/Thumbnails/Stations/gw1m.jpg",
"Idzikowice": "https://api.simrail.eu:8083/Thumbnails/Stations/id1m.jpg",
"Katowice Zawodzie": "https://api.simrail.eu:8083/Thumbnails/Stations/kz1m.jpg",
"Sosnowiec Główny": "https://api.simrail.eu:8083/Thumbnails/Stations/sg1m.jpg",
"Dąbrowa Górnicza": "https://api.simrail.eu:8083/Thumbnails/Stations/dg1m.jpg",
"Zawiercie": "https://api.simrail.eu:8083/Thumbnails/Stations/zw1m.jpg",
"Będzin": "https://api.simrail.eu:8083/Thumbnails/Stations/b1m.jpg",
"Sosnowiec Południowy": "https://api.simrail.eu:8083/Thumbnails/Stations/spl1m.jpg",
"Opoczno Południe": "https://api.simrail.eu:8083/Thumbnails/Stations/op1m.jpg",
"Dąbrowa Górnicza Wschodnia": "https://api.simrail.eu:8083/Thumbnails/Stations/dws1m.jpg",
"Dorota": "https://api.simrail.eu:8083/Thumbnails/Stations/dra1m.jpg",
"Łazy Ła": "https://api.simrail.eu:8083/Thumbnails/Stations/la1m.jpg",
"Łazy": "https://api.simrail.eu:8083/Thumbnails/Stations/lb1m.jpg",
"Juliusz": "https://api.simrail.eu:8083/Thumbnails/Stations/ju1m.jpg",
"Łazy Łc": "https://api.simrail.eu:8083/Thumbnails/Stations/lc1m.jpg",
"Katowice": "https://api.simrail.eu:8083/Thumbnails/Stations/ko1m.jpg",
"Dąbrowa Górnicza Ząbkowice": "https://api.simrail.eu:8083/Thumbnails/Stations/dz1m.jpg",
"Sławków": "https://api.simrail.eu:8083/Thumbnails/Stations/sl1m.jpg",
"Starzyny": "https://api.simrail.eu:8083/Thumbnails/Stations/str1m.jpg",
"Bukowno": "https://api.simrail.eu:8083/Thumbnails/Stations/bo1m.jpg",
"Tunel": "https://api.simrail.eu:8083/Thumbnails/Stations/tl1m.jpg",
"Dąbrowa Górnicza Huta Katowice": "https://api.simrail.eu:8083/Thumbnails/Stations/dghk1m.jpg",
"Sosnowiec Kazimierz": "https://api.simrail.eu:8083/Thumbnails/Stations/skz1m.jpg",
"Pruszków": "https://api.simrail.eu:8083/Thumbnails/Stations/pr1m.jpg",
"Strzałki": "https://api.simrail.eu:8083/Thumbnails/Stations/st1m.jpg",
"Olszamowice": "https://api.simrail.eu:8083/Thumbnails/Stations/ol1m.jpg",
"Miechów": "https://api.simrail.eu:8083/Thumbnails/Stations/mi1m.jpg",
"Kraków Przedmieście": "https://api.simrail.eu:8083/Thumbnails/Stations/kpm1m.jpg",
"Kraków Batowice": "https://api.simrail.eu:8083/Thumbnails/Stations/kb1m.jpg",
"Raciborowice": "https://api.simrail.eu:8083/Thumbnails/Stations/ra1m.jpg",
"Zastów": "https://api.simrail.eu:8083/Thumbnails/Stations/zs1m.jpg",
"Niedźwiedź": "https://api.simrail.eu:8083/Thumbnails/Stations/nd1m.jpg",
"Słomniki": "https://api.simrail.eu:8083/Thumbnails/Stations/sm1m.jpg",
"Kozłów": "https://api.simrail.eu:8083/Thumbnails/Stations/koz1m.jpg",
"N/A": 'https://shared.steamstatic.com/store_item_assets/steam/apps/1422130/header.jpg'
};
export const trainsMap: Record<string, string> = {
"Traxx (E186)": "https://wiki.simrail.eu/vehicle/poland/trains/elec-loco/traxx/20241029163359_1.jpg",
"Dragon2 (E6ACTa, E6ACTadb)": "https://wiki.simrail.eu/vehicle/e6acta-016.jpg",
"Dragon2 (ET25)": "https://wiki.simrail.eu/vehicle/et25-002.jpg",
"Pendolino (ED250)": "https://wiki.simrail.eu/vehicle/ed250-001.png",
"EN57": "https://wiki.simrail.eu/vehicle/en57-009.png",
"EN71": "https://wiki.simrail.eu/vehicle/en71-002.png",
"EN76": "https://wiki.simrail.eu/vehicle/en76-006.jpg",
"EN96": "https://wiki.simrail.eu/vehicle/en96-001.jpg",
"EP07": "https://wiki.simrail.eu/vehicle/ep07-174.jpg",
"EP08": "https://wiki.simrail.eu/vehicle/poland/trains/elec-loco/ep08/20241106002003_1.jpg",
"ET22": "https://wiki.simrail.eu/vehicle/et22-243.png",
"EU07": "https://wiki.simrail.eu/vehicle/eu07-005.jpg",
"Ty2": "https://wiki.simrail.eu/vehicle/ty2-70.png",
"N/A": 'https://shared.steamstatic.com/store_item_assets/steam/apps/1422130/header.jpg'
};
export const getVehicle = (name: string) =>
{
return trainsList.find(x => wcmatch(x.pattern)(name))?.train;
};
};

View File

@ -16,59 +16,58 @@
import { Dispatch, SetStateAction } from "react";
const getPaginationNums = (page: number, pages: number) => {
if (pages <= 5)
return Array.from({ length: pages }, (_, i) => i + 1);
const numbers = [1];
if (page <= 3) {
numbers.push(2, 3, 4);
} else if (page >= pages - 2) {
numbers.push(pages - 3, pages - 2, pages - 1);
} else {
numbers.push(page - 1, page, page + 1);
}
numbers.push(pages);
return [...new Set(numbers)].sort((a, b) => a - b);
}
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);
}) => {
// todo: rewrite this shit XDDDDDDDD
const numbers = getPaginationNums(page, pages);
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">
className="rounded-sm 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) }>
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">
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>
fill=""></path>
</svg>
</a></li>
{ numbers.map(num =>
{
{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>
<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) }>
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">
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="">
fill="">
</path>
</svg>
</a>

View File

@ -15,9 +15,8 @@
*/
import { useState } from "react";
import { TProfileData } from "../../../types/profile.ts";
import { TProfileData, TProfilePlayer } from "../../../types/profile.ts";
import { useTranslation } from "react-i18next";
import { ArrowIcon, FlexArrowIcon } from "../../mini/icons/ArrowIcon.tsx";
import { formatTime } from "../../../util/time.ts";
import { useAuth } from "../../../hooks/useAuth.tsx";
import { ConfirmModal } from "../../mini/modal/ConfirmModal.tsx";
@ -25,258 +24,253 @@ import { post } from "../../../util/fetcher.ts";
import { toast } from "react-toastify";
import dayjs from "dayjs";
import { UserIcons } from "../../mini/icons/UserIcons.tsx";
import { StationStat } from '../../mini/profile/StationStat.tsx';
import { chunk } from '../../../util/chunk.ts';
import { Paginator } from '../../mini/util/Paginator.tsx';
import { TrainStat } from '../../mini/profile/TrainStat.tsx';
import { TImagesData } from '../../../types/images.ts';
export const ProfileCard = ({ data }: { data: TProfileData }) =>
{
const sortTrainsByList: Record<number, string> = {
[0]: 'time',
[1]: 'score',
[2]: 'distance',
}
const [ showTrains, setShowTrains ] = useState(false);
const [ showStations, setShowStations ] = useState(false);
const [ sortTrainsBy, setSortTrainsBy ] = useState<"time" | "score" | "distance">("distance");
const [ hideLeaderboardStatsModal, setHideLeaderboardStatsModal ] = useState(false);
const [ hideProfileModal, setHideProfileModal ] = useState(false);
export const ProfileCard = ({ data, images }: { data: TProfileData, images: TImagesData }) => {
const [sortTrainsBy, setSortTrainsBy] = useState(0);
const [sortTrainsBy2, setSortTrainsBy2] = useState(2);
const [sortStationsBy, setSortStationsBy] = useState(0);
const [hideLeaderboardStatsModal, setHideLeaderboardStatsModal] = useState(false);
const [hideProfileModal, setHideProfileModal] = useState(false);
const { isAdmin, token } = useAuth();
const adminToggleHideLeaderboardPlayerProfile = () =>
{
post(`/admin/profile/${ data.player.id }/${ data.player.flags.includes("leaderboard_hidden") ? "showLeaderboard" : "hideLeaderboard" }`, {}, { "X-Auth-Token": token })
.then((response) =>
{
if (response.code === 200)
{
toast.success(t("admin.hideLeaderboard.alert"));
}
});
// #region ADMIN
const adminToggleHideLeaderboardPlayerProfile = () => {
post(`/admin/profile/${data.player.id}/${data.player.flags.includes("leaderboard_hidden") ? "showLeaderboard" : "hideLeaderboard"}`, {}, { "X-Auth-Token": token })
.then((response) => {
if (response.code === 200) {
toast.success(t("admin.hideLeaderboard.alert"));
}
});
};
const adminHidePlayerProfile = () =>
{
post(`/admin/profile/${ data.player.id }/hide`, {}, { "X-Auth-Token": token })
.then((response) =>
{
if (response.code === 200)
{
toast.success(t("admin.hide.alert"));
}
});
const adminHidePlayerProfile = () => {
post(`/admin/profile/${data.player.id}/hide`, {}, { "X-Auth-Token": token })
.then((response) => {
if (response.code === 200) {
toast.success(t("admin.hide.alert"));
}
});
};
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 adminForceUpdate = () => {
post(`/admin/profile/${data.player.id}/forceUpdate`, {}, { "X-Auth-Token": token })
.then((response) => {
if (response.code === 200) {
toast.success(t("admin.update.alert"));
}
});
};
// #endregion
const sortStations = (a: keyof TProfilePlayer['dispatcherStats'], b: keyof TProfilePlayer['dispatcherStats']) => {
if (sortStationsBy) {
const _a = a;
a = b;
b = _a;
}
return data.player.dispatcherStats[b].time - data.player.dispatcherStats[a].time
}
const sortTrains = (a: keyof TProfilePlayer['trainStats'], b: keyof TProfilePlayer['trainStats']) => {
if (sortTrainsBy2) {
const _a = a;
a = b;
b = _a;
}
return data.player.trainStats[b][(sortTrainsByList[sortTrainsBy] ?? 'distance') as 'distance'] - data.player.trainStats[a][(sortTrainsByList[sortTrainsBy] ?? 'distance') as 'distance'];
}
const dispatcherStats = [...chunk(Object.keys(data.player.dispatcherStats), 8)];
const [dispatcherPage, setDispatcherPage] = useState(1);
const trainStats = [...chunk(Object.keys(data.player.trainStats), 8)];
const [trainPage, setTrainPage] = useState(1);
const { t } = useTranslation();
return <>
<ConfirmModal showModal={ hideLeaderboardStatsModal } setShowModal={ setHideLeaderboardStatsModal }
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") }
description={ t("admin.hide.modal.description") }/>
<ConfirmModal showModal={hideLeaderboardStatsModal} setShowModal={setHideLeaderboardStatsModal}
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")}
description={t("admin.hide.modal.description")} />
<div
className="overflow-hidden rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
className="overflow-hidden ">
<div className="px-4 pt-6 text-center lg:pb-8 xl:pb-11.5">
<div
className="mx-auto max-w-44 rounded-full">
className="mx-auto 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> }
<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="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
className="mx-auto mt-4.5 mb-5.5 grid max-w-94 grid-cols-2 rounded-md border border-stroke py-2.5 shadow-1 dark:border-strokedark dark:bg-[#37404F]">
className="mx-auto mt-4.5 mb-5.5 grid max-w-94 grid-cols-2 rounded-md border border-stroke py-2.5 shadow-1 dark:border-strokedark dark:bg-[#37404F]">
<div
className="flex flex-col items-center justify-center gap-1 border-r border-stroke px-4 dark:border-strokedark xsm:flex-row">
className="flex flex-col items-center justify-center gap-1 border-r border-stroke px-4 dark:border-strokedark xsm:flex-row">
<span className="font-semibold text-black dark:text-white">
{ Math.floor(data.player.trainDistance / 1000) }km
{Math.floor(data.player.trainDistance / 1000)}km
</span>
<span className="text-sm text-wrap">{ t("profile.stats.distance") }</span>
<span className="text-sm text-wrap">{t("profile.stats.distance")}</span>
</div>
<div
className="flex flex-col items-center justify-center gap-1 border-r border-stroke px-4 dark:border-strokedark xsm:flex-row">
className="flex flex-col items-center justify-center gap-1 border-r border-stroke px-4 dark:border-strokedark xsm:flex-row">
<span className="font-semibold text-black dark:text-white">
{ formatTime(data.player.dispatcherTime) }
{formatTime(data.player.dispatcherTime)}
</span>
<span className="text-sm text-wrap">{ t("profile.stats.time") }</span>
<span className="text-sm text-wrap">{t("profile.stats.time")}</span>
</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 === "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> }
{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>
<div className="px-5 pt-6 pb-5 sm:px-7.5 rounded-md">
<h1 className="text-xl text-black dark:text-white pb-5">{t("profile.stations.header")}</h1>
<div className="flex flex-row gap-4">
<a className='cursor-pointer' onClick={() => setSortStationsBy((prev) => prev === 0 ? 1 : 0)}><p className='text-base'>
<strong>{t('profile.stations.sortby.title')}</strong> {sortStationsBy === 0 ? t('profile.stations.sortby.max') : t('profile.stations.sortby.min')}
</p></a>
</div>
<div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4 pt-4">
{dispatcherStats[dispatcherPage - 1].sort(sortStations).map(stationName => {
const station = data.player.dispatcherStats[stationName];
{ 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) }>
<h1 className="text-xl text-black dark:text-white pb-5">{ t("profile.trains.header") }</h1>
<ArrowIcon rotated={ showTrains }/>
return <StationStat stationName={stationName} time={station.time} image={images.stations[stationName]} />
})}
</div>
<div className="flex flex-col pt-4">
<Paginator setPage={setDispatcherPage} page={dispatcherPage} pages={dispatcherStats.length} />
</div>
</div>
<div className="px-5 pt-6 pb-5 sm:px-7.5 rounded-md">
<h1 className="text-xl text-black dark:text-white pb-5">{t("profile.trains.header")}</h1>
<div className="flex flex-col">
<a className='cursor-pointer' onClick={() => setSortTrainsBy2((prev) => prev === 0 ? 1 : 0)}><p className='text-base'>
<strong>{t('profile.trains.sortby.title')}</strong> {sortTrainsBy2 === 0 ? t('profile.trains.sortby.max') : t('profile.trains.sortby.min')}
</p></a>
<a className='cursor-pointer' onClick={() => setSortTrainsBy((prev) => {
prev++;
if (prev > 2) prev = 0;
return prev;
})}><p className='text-base'>
<strong>{t('profile.trains.sortby.title')}</strong> {t('profile.trains.sortby.' + sortTrainsByList[sortTrainsBy])}
</p></a>
</div>
<div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4 pt-4">
{trainStats[trainPage - 1].sort(sortTrains).map(trainName => {
const train = data.player.trainStats[trainName];
return <TrainStat trainName={trainName} time={train.time} distance={train.distance} score={train.score} image={images.trains[trainName]} />
})}
</div>
<div className="flex flex-col pt-4">
<Paginator setPage={setTrainPage} page={trainPage} pages={trainStats.length} />
</div>
</div>
{/* <div className="bg-white px-5 pt-6 pb-5 shadow-default dark:bg-boxdark sm:px-7.5">
<div className="flex flex-col rounded-sm border border-stroke dark:border-strokedark">
<div className="grid grid-cols-2 rounded-sm bg-gray-2 dark:bg-meta-4">
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{t("profile.stations.station")}
</h5>
</div>
{ 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-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") }
</h5>
</div>
<div className="flex flex-row align-center justify-center gap-2 p-2.5 text-center xl:p-5 cursor-pointer"
onClick={ () => setSortTrainsBy("distance") }>
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("profile.trains.distance") }
</h5>
<FlexArrowIcon rotated={ sortTrainsBy === "distance" || !sortTrainsBy }/>
</div>
<div className="flex flex-row align-center justify-center gap-2 p-2.5 text-center xl:p-5 cursor-pointer"
onClick={ () => setSortTrainsBy("score") }>
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("profile.trains.points") }
</h5>
<FlexArrowIcon rotated={ sortTrainsBy === "score" }/>
</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 =>
{
const train = data.player.trainStats[ trainName ];
return <div
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">
<p className="text-black dark:text-white sm:block break-all">
{ trainName }
</p>
</div>
<div className="flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-6 sm:block break-all">{ Math.floor(train.distance / 1000) }km</p>
</div>
<div className="flex items-center justify-center p-2.5 lg:p-5">
<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>;
}) }
</div> }
</div> }
{ Object.keys(data.player.dispatcherStats || {}).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={ () => setShowStations(val => !val) }>
<h1 className="text-xl text-black dark:text-white pb-5">{ t("profile.stations.header") }</h1>
<ArrowIcon rotated={ showStations }/>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{t("profile.stations.time")}
</h5>
</div>
{ showStations &&
<div className="flex flex-col rounded-sm border border-stroke dark:border-strokedark">
<div className="grid grid-cols-2 rounded-sm bg-gray-2 dark:bg-meta-4">
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("profile.stations.station") }
</h5>
</div>
</div>
{Object.keys(data.player.dispatcherStats).sort((a, b) => data.player.dispatcherStats[b].time - data.player.dispatcherStats[a].time).map(stationName => {
const station = data.player.dispatcherStats[stationName];
return <div
className={`grid grid-cols-2 border-t border-t-stroke dark:border-t-strokedark`}
key={stationName}
>
<div className="flex items-center justify-center gap-3 p-2.5 lg:p-5">
<p className="text-black dark:text-white sm:block break-all">
{stationName}
</p>
</div>
<div className="p-2.5 text-center xl:p-5">
<h5 className="text-sm font-medium uppercase xsm:text-base">
{ t("profile.stations.time") }
</h5>
</div>
</div>
{ Object.keys(data.player.dispatcherStats).sort((a, b) => data.player.dispatcherStats[ b ].time - data.player.dispatcherStats[ a ].time).map(stationName =>
{
const station = data.player.dispatcherStats[ stationName ];
return <div
className={ `grid grid-cols-2 border-t border-t-stroke dark:border-t-strokedark` }
key={ stationName }
>
<div className="flex items-center justify-center gap-3 p-2.5 lg:p-5">
<p className="text-black dark:text-white sm:block break-all">
{ stationName }
</p>
</div>
<div className="flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-3">{formatTime(station.time)}</p>
</div>
</div>;
})}
<div className="flex items-center justify-center p-2.5 lg:p-5">
<p className="text-meta-3">{ formatTime(station.time) }</p>
</div>
</div>;
}) }
</div>
</div> }
</div> }
{ isAdmin && <>
<div className="shadow-default dark:bg-boxdark items-center justify-center p-2.5 flex flex-col xl:p-5 gap-2">
<h1 className="text-xl text-black dark:text-white">{ t("admin.header") }</h1>
</div> */}
{isAdmin && <>
<div className="shadow-default items-center justify-center p-2.5 flex flex-col xl:p-5 gap-2">
<h1 className="text-xl text-black dark:text-white">{t("admin.header")}</h1>
<div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap sm:flex-nowrap">
{ data.player.flags.includes("leaderboard_hidden") ?
<button className={ "inline-flex items-center justify-center rounded-md py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5 bg-success" }
onClick={ () => adminToggleHideLeaderboardPlayerProfile() }>
{ t("admin.hideLeaderboard.button2") }
</button> :
<button className={ "inline-flex items-center justify-center rounded-md py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5 bg-danger" }
onClick={ () => setHideLeaderboardStatsModal(true) }>
{ t("admin.hideLeaderboard.button") }
</button> }
{data.player.flags.includes("leaderboard_hidden") ?
<button className={"inline-flex items-center justify-center rounded-md py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5 bg-success"}
onClick={() => adminToggleHideLeaderboardPlayerProfile()}>
{t("admin.hideLeaderboard.button2")}
</button> :
<button className={"inline-flex items-center justify-center rounded-md py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5 bg-danger"}
onClick={() => setHideLeaderboardStatsModal(true)}>
{t("admin.hideLeaderboard.button")}
</button>}
<button className="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"
onClick={ () => setHideProfileModal(true) }>
{ t("admin.hide.button") }
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") }
onClick={() => adminForceUpdate()}>
{t("admin.update.button")}
</button>
</div>
</div>
</> }
</>}
<div className="shadow-default dark:bg-boxdark items-center justify-center p-2.5 flex flex-col xl:p-5 gap-2">
<div className="items-center justify-center p-2.5 flex flex-col xl:p-5 gap-2">
<h1 className="text-sm text-black dark:text-white">
{ t("profile.info", { date: dayjs(data.player.createdAt).format("DD/MM/YYYY") }) }
{t("profile.info", { date: dayjs(data.player.createdAt).format("DD/MM/YYYY") })}
</h1>
</div>

View File

@ -63,14 +63,27 @@
"trains": {
"header": "Statistiky vlaků",
"train": "Vlak",
"distance": "Vzdálenost",
"points": "Body",
"time": "Čas"
"distance": "Vzdálenost: {{distance}}km",
"score": "Body: {{score}",
"time": "Čas: {{time}}",
"sortby": {
"title": "Řadit podle: ",
"min": "Nejnižší",
"max": "Nejvyšší",
"time": "Čas",
"distance": "Vzdálenost",
"score": "Body"
}
},
"stations": {
"header": "Statistiky stanic",
"station": "Stanice",
"time": "Čas"
"time": "Čas: {{time}}",
"sortby": {
"title": "Řadit podle: ",
"min": "Nejnižší",
"max": "Nejvyšší"
}
},
"errors": {
"notfound": {

View File

@ -63,14 +63,27 @@
"trains": {
"header": "Train Statistics",
"train": "Train",
"distance": "Distance",
"points": "Points",
"time": "Time"
"distance": "Distance: {{distance}}km",
"score": "Points: {{score}}",
"time": "Time: {{time}}",
"sortby": {
"title": "Sort: ",
"min": "Lowest",
"max": "Highest",
"time": "Time",
"distance": "Distance",
"score": "Points"
}
},
"stations": {
"header": "Station Statistics",
"station": "Station",
"time": "Time"
"time": "Time: {{time}}",
"sortby": {
"title": "Sort: ",
"min": "Lowest",
"max": "Highest"
}
},
"errors": {
"notfound": {

View File

@ -63,14 +63,27 @@
"trains": {
"header": "Statystyki pociągów",
"train": "Pociąg",
"distance": "Dystans",
"points": "Punkty",
"time": "Czas"
"distance": "Dystans: {{distance}}km",
"score": "Punkty: {{score}}",
"time": "Czas: {{time}}",
"sortby": {
"title": "Sortowanie: ",
"min": "Od najniższej",
"max": "Od najwyższej",
"time": "Czas",
"distance": "Dystans",
"score": "Punkty"
}
},
"stations": {
"header": "Statystyki stacji",
"station": "Stacja",
"time": "Czas"
"time": "Czas: {{time}}",
"sortby": {
"title": "Sortowanie: ",
"min": "Od najniższej",
"max": "Od najwyższej"
}
},
"errors": {
"notfound": {

View File

@ -27,37 +27,37 @@ import useSWR from "swr";
import { get } from "../../util/fetcher.ts";
export const Profile = () =>
{
export const Profile = () => {
const { id } = useParams();
const { data, error, isLoading } = useSWR(`/profiles/${ id }`, get, { refreshInterval: 5_000, errorRetryCount: 5 });
const { data, error, isLoading } = useSWR(`/profiles/${id}`, get, { refreshInterval: 5_000, errorRetryCount: 5 });
const images = useSWR(`/images/`, get);
const { t } = useTranslation();
return (
<>
{/* LOADING */ }
{ isLoading && <ContentLoader/> }
{/* 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."/> }
{ data && data.code === 403 && <WarningAlert title={ t("profile.errors.blacklist.title") }
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."/> }
{ data && data.code === 404 && <WarningAlert title={ t("profile.errors.notfound.title") }
description={ t("profile.errors.notfound.description") }/> }
<>
{/* LOADING */}
{(isLoading || images.isLoading) && <ContentLoader />}
{/* ERROR */}
{(error || images.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." />}
{data && data.code === 403 && <WarningAlert title={t("profile.errors.blacklist.title")}
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." />}
{data && data.code === 404 && <WarningAlert title={t("profile.errors.notfound.title")}
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 |
${ data.data.player.dispatcherTime ? 0 : formatTime(data.data.player.dispatcherTime) } dispatcher experience` }/> }
{ data && data.code === 200 && <ProfileCard data={ data.data }/> }
</>
{/* SUCCESS */}
{data && data.code === 200 && images.data && images.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 |
${data.data.player.dispatcherTime ? 0 : formatTime(data.data.player.dispatcherTime)} dispatcher experience`} />}
{data && data.code === 200 && images.data && images.data.code === 200 && <ProfileCard data={data.data} images={images.data.data} />}
</>
);
};

View File

@ -14,6 +14,6 @@
* See LICENSE for more.
*/
export const get = (url: string) => fetch(`${ import.meta.env.VITE_API_URL }${ url }`, { signal: AbortSignal.timeout(2500) }).then((res) => res.json());
export const get = (url: string) => fetch(`${ import.meta.env.VITE_API_URL }${ url }`, { signal: AbortSignal.timeout(10000) }).then((res) => res.json());
export const post = (url: string, body?: any, headers: Record<string, string> = {}) => fetch(`${ import.meta.env.VITE_API_URL }${ url }`, { signal: AbortSignal.timeout(2500), method: "POST", body: JSON.stringify(body), headers: Object.assign(headers, { "Content-Type": "application/json" }) }).then((res) => res.json());
export const post = (url: string, body?: any, headers: Record<string, string> = {}) => fetch(`${ import.meta.env.VITE_API_URL }${ url }`, { signal: AbortSignal.timeout(10000), method: "POST", body: JSON.stringify(body), headers: Object.assign(headers, { "Content-Type": "application/json" }) }).then((res) => res.json());