feat(frontend, backend): moderation panel

This commit is contained in:
Aleksander Wilczyński 2024-12-05 00:08:45 +01:00
parent daed28fa91
commit 0d949833d6
Signed by untrusted user: alekswilc
GPG Key ID: D4464A248E5F27FE
26 changed files with 725 additions and 181 deletions

View File

@ -0,0 +1,163 @@
/*
* Copyright (C) 2024 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* See LICENSE for more.
*/
import { Router } from "express";
import { ErrorResponseBuilder, SuccessResponseBuilder } from "../responseBuilder.js";
import { MAdmin } from "../../mongo/admin.js";
import { MProfile } from "../../mongo/profile.js";
export class AdminRoute
{
static load()
{
const app = Router();
app.get("/auth", async (req, res) =>
{
const token = req.query.token;
if (!token)
{
res.status(400).json(new ErrorResponseBuilder()
.setCode(400)
.setData("Missing token query").toJSON());
return;
}
const data = await MAdmin.findOne({ token });
if (!data)
{
res.status(401).json(new ErrorResponseBuilder()
.setCode(401)
.setData("Invalid token").toJSON());
return;
}
res.json(
new SuccessResponseBuilder()
.setCode(200)
.setData({
isAdmin: true,
username: data.username,
})
.toJSON(),
);
});
app.post("/profile/:playerId/clear", async (req, res) =>
{
const token = req.headers["x-auth-token"];
if (!token)
{
res.status(400).json(new ErrorResponseBuilder()
.setCode(400)
.setData("Missing token query").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;
}
await MProfile.updateOne({id: player.id}, {
dispatcherTime: 0,
trainTime: 0,
trainDistance: 0,
trainStats: {},
dispatcherStats: {},
});
res.json(
new SuccessResponseBuilder()
.setCode(200)
.setData({})
.toJSON(),
);
});
app.post("/profile/:playerId/hide", 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;
}
player.flags.push("hidden");
await MProfile.updateOne({id: player.id}, {
flags: player.flags,
});
res.json(
new SuccessResponseBuilder()
.setCode(200)
.setData({})
.toJSON(),
);
});
return app;
}
}

View File

@ -22,10 +22,10 @@ import { escapeRegexString, removeProperties } from "../../util/functions.js";
const generateSearch = (regex: RegExp) => [
{
steam: { $regex: regex },
id: { $regex: regex },
},
{
steamName: { $regex: regex },
username: { $regex: regex },
},
];
@ -45,7 +45,13 @@ export class LeaderboardRoute
{
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
const filter: PipelineStage[] = [];
const filter: PipelineStage[] = [
{
$match: {
flags: { $nin: ["hidden"] }
}
}
];
s && filter.push({
$match: {
@ -74,7 +80,13 @@ export class LeaderboardRoute
{
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
const filter: PipelineStage[] = [];
const filter: PipelineStage[] = [
{
$match: {
flags: { $nin: ["hidden"] }
}
}
];
s && filter.push({
$match: {
$and: [

View File

@ -38,7 +38,11 @@ export class LogRoute
return;
}
const log = await MStationLog.findOne({ id }).populate<{ player: IProfile }>('player').orFail().catch(() => null) || await MTrainLog.findOne({ id }).populate<{ player: IProfile }>('player').orFail().catch(() => null);
const log = await MStationLog.findOne({ id }).populate<{
player: IProfile
}>("player").orFail().catch(() => null) || await MTrainLog.findOne({
id,
}).populate<{ player: IProfile }>("player").orFail().catch(() => null);
if (!log)
{
@ -48,8 +52,16 @@ export class LogRoute
return;
}
if (log.player.flags.includes("hidden"))
{
res.status(403).json(new ErrorResponseBuilder()
.setCode(403)
.setData("Log blocked!").toJSON());
return;
}
res.status(200).json(new SuccessResponseBuilder().setCode(200).setData({
...log.toJSON()
...log,
}));
});

View File

@ -40,10 +40,10 @@ export class ProfilesRoute
return;
}
if (player.flags.includes('blacklist'))
if (player.flags.includes('hidden'))
{
res.status(403).json(new ErrorResponseBuilder()
.setCode(403).setData("Profile blacklisted!"));
.setCode(403).setData("Profile blocked!"));
return;
}

View File

@ -23,6 +23,7 @@ import cors from "cors";
import { StatsRoute } from "./routes/stats.js";
import { LogRoute } from "./routes/log.js";
import { ActivePlayersRoute } from "./routes/activePlayer.js";
import { AdminRoute } from "./routes/admin.js";
export class ApiModule
{
@ -37,6 +38,7 @@ export class ApiModule
router.use("/profiles/", ProfilesRoute.load());
router.use("/leaderboard/", LeaderboardRoute.load());
router.use("/active/", ActivePlayersRoute.load());
router.use("/admin/", AdminRoute.load());
router.use("/stats/", StatsRoute.load());
router.use("/log/", LogRoute.load());

View File

@ -0,0 +1,40 @@
/*
* 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 { HydratedDocument, model, Schema } from "mongoose";
export const raw_schema = {
username: {
type: String,
required: true,
},
token: {
type: String,
required: true,
},
};
const schema = new Schema<IAdmin>(raw_schema);
export type TMAdmin = HydratedDocument<IAdmin>;
export const MAdmin = model<IAdmin>("admin", schema);
export interface IAdmin
{
token: string
username: string
}

View File

@ -34,6 +34,8 @@ import { HelmetProvider } from "react-helmet-async";
import { PageMeta } from "./components/mini/util/PageMeta.tsx";
import { ActiveStationsPlayers } from "./pages/activePlayers/ActiveStationsPlayers.tsx";
import { ActiveTrainPlayers } from "./pages/activePlayers/ActiveTrainPlayers.tsx";
import { AuthProvider } from "./hooks/useAuth.tsx";
import { NotFoundError } from "./pages/errors/NotFound.tsx";
function App()
{
@ -52,8 +54,7 @@ function App()
return <HelmetProvider>
<AuthProvider>
{ loading ? (
<Loader/>
) : (
@ -170,10 +171,20 @@ function App()
</>
}
/>
<Route
path="*"
element={
<>
<NotFoundError/>
</>
}
/>
</Routes>
</DefaultLayout>
</>
) }
</AuthProvider>
</HelmetProvider>;
}

View File

@ -0,0 +1,61 @@
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; }) => {
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">
<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)}
>
<span className="bg-transparent text-meta-2 opacity-5 h-6 w-6 text-2xl block outline-none focus:outline-none">
×
</span>
</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="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 className="opacity-25 fixed inset-0 z-40 bg-black"></div>
</>
) : null}
</>
);
}

View File

@ -22,6 +22,7 @@ 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 { useAuth } from "../../../hooks/useAuth.tsx";
interface SidebarProps
{
@ -95,6 +96,8 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
}
}, [ sidebarExpanded ]);
const { isAdmin, username } = useAuth();
return (
<aside
ref={ sidebar }
@ -337,6 +340,23 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
} }
</SidebarLinkGroup>
</ul>
{ isAdmin && <ul className="mb-6 flex flex-col gap-1.5">
<h3 className="ml-4 text-sm font-semibold text-bodydark2">
{ t("sidebar.admin") }
</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>
</li>
<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")}
</button>
</ul> }
</div>
{/* TODO: add admin panel with simple auth */ }

View File

@ -77,7 +77,8 @@ export const ActiveStationTable = ({ stations }: { stations: TActiveStationPlaye
<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"
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>

View File

@ -20,6 +20,11 @@ import { useTranslation } from "react-i18next";
import { ArrowIcon, FlexArrowIcon } from "../../mini/icons/ArrowIcon.tsx";
import { formatTime } from "../../../util/time.ts";
import { FaCheck } from "react-icons/fa6";
import { useAuth } from "../../../hooks/useAuth.tsx";
import { ConfirmModal } from "../../mini/modal/ConfirmModal.tsx";
import { post } from "../../../util/fetcher.ts";
import { toast } from "react-toastify";
export const ProfileCard = ({ data }: { data: TProfileData }) =>
{
@ -27,154 +32,216 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
const [ showTrains, setShowTrains ] = useState(false);
const [ showStations, setShowStations ] = useState(false);
const [ sortTrainsBy, setSortTrainsBy ] = useState<"time" | "score" | "distance">("distance");
const [ clearStatsModal, setClearStatsModal ] = useState(false);
const [ hideProfileModal, setHideProfileModal ] = useState(false);
const { isAdmin, token } = useAuth();
const adminClearPlayerStats = () =>
{
post(`/admin/profile/${ data.player.id }/clear`, {}, { "X-Auth-Token": token })
.then((response) =>
{
if (response.code === 200)
{
toast.success(t("admin.clear.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 { t } = useTranslation();
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"/>
</div>
</div>
<div className="mt-4">
<h3 className="mb-1.5 text-2xl font-semibold text-black dark:text-white">
{ data.player.username } { data.player.flags.includes('verified') &&
<FaCheck className={ "inline text-meta-3 ml-1" }/> }
</h3>
return <>
<ConfirmModal showModal={ clearStatsModal } setShowModal={ setClearStatsModal }
onConfirm={ adminClearPlayerStats } title={ t("admin.clear.modal.title") }
description={ t("admin.clear.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">
<div className="px-4 pt-6 text-center lg:pb-8 xl:pb-11.5">
<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]">
<div
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
</span>
<span className="text-sm text-wrap">{ t("profile.stats.distance") }</span>
className="mx-auto w-full max-w-30 rounded-full bg-white/20 p-1 backdrop-blur sm:h-44 sm:max-w-44 sm:p-3">
<div className="relative drop-shadow-2">
<img className="rounded-full" src={ data.player.avatar } alt="profile"/>
</div>
</div>
<div className="mt-4">
<h3 className="mb-1.5 text-2xl font-semibold text-black dark:text-white">
{ data.player.username } { data.player.flags.includes("verified") &&
<FaCheck className={ "inline text-meta-3 ml-1" }/> }
</h3>
<div
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) }
</span>
<span className="text-sm text-wrap">{ t("profile.stats.time") }</span>
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">
<span className="font-semibold text-black dark:text-white">
{ Math.floor(data.player.trainDistance / 1000) }km
</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">
<span className="font-semibold text-black dark:text-white">
{ formatTime(data.player.dispatcherTime) }
</span>
<span className="text-sm text-wrap">{ t("profile.stats.time") }</span>
</div>
</div>
</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) }>
<h1 className="text-xl text-black dark:text-white pb-5">{ t("profile.trains.header") }</h1>
<ArrowIcon rotated={ showTrains }/>
</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-4">
<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-4 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>
{ 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 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> }
</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">Moderator actions</h1>
<div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap sm:flex-nowrap">
<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={ () => setClearStatsModal(true) }>
Clear stats
</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) }>
Hide profile
</button>
</div>
</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) }>
<h1 className="text-xl text-black dark:text-white pb-5">{ t("profile.trains.header") }</h1>
<ArrowIcon rotated={ showTrains }/>
</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-4">
<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-4 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>
{ 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 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> }
</div> }
</div>;
</>;
};

View File

@ -0,0 +1,31 @@
import { useContext, createContext, ReactNode } from "react";
import useSWR from "swr";
import { get } from "../util/fetcher.ts";
import useLocalStorage from "./useLocalStorage.tsx";
export type AdminContext = { isAdmin: boolean; username: string; token: string; };
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);
if (!value || value === 'undefined')
return { isAdmin: false, username: '', token: '' };
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>;
};
export const useAuth = () => {
return useContext(AuthContext);
};

View File

@ -28,10 +28,16 @@ function useLocalStorage<T>(
try
{
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
if (item) {
try {
return item ? JSON.parse(item) : initialValue;
}
catch {
return item ? item : initialValue;
}
}
} catch (error)
{
console.log(error);
return initialValue;
}
});

View File

@ -28,6 +28,11 @@
"author": "Created by <anchor>{{author}}</anchor> with ❤️ for the Simrail community"
}
},
"notfound": {
"title": "Not Found",
"description": "It seems you're lost.",
"button": "Return to homepage."
},
"leaderboard": {
"user": "Player",
"time": "Time",
@ -92,7 +97,7 @@
},
"blacklist": {
"title": "Unable to display profile",
"description": "This player's profile has been blocked."
"description": "The player's profile could not be displayed due to active moderator actions."
}
}
},
@ -104,7 +109,7 @@
},
"blacklist": {
"title": "The record cannot be displayed",
"description": "The record has been blocked."
"description": "This record could not be displayed due to active moderator actions."
}
},
"station": {
@ -143,7 +148,25 @@
"leaderboard": "Leaderboard",
"active_players": "Active Players",
"info": "INFO",
"admin": "ADMIN"
"admin": "ADMIN",
"logged": "Logged as {{username}}",
"logout": "Log out"
},
"admin": {}
"admin": {
"clear": {
"modal": {
"title": "Are you sure?",
"description": "This action will permanently clear user statistics."
},
"alert": "Player stats cleared."
},
"hide": {
"modal": {
"title": "Are you sure?",
"description": "This action will hide user profile from displaying in the leaderboard."
},
"alert": "Player profile hidden."
}
}
}

View File

@ -28,6 +28,11 @@
"author": "Stworzone przez <anchor>{{author}}</anchor> z ❤️ dla społeczności Simrail"
}
},
"notfound": {
"title": "Nie znaleziono strony",
"description": "Wygląda na to, że się zgubiłeś.",
"button": "Wróć do strony głównej"
},
"leaderboard": {
"user": "Gracz",
"time": "Czas",
@ -92,7 +97,7 @@
},
"blacklist": {
"title": "Nie można wyświetlić profilu",
"description": "Profil tego gracza został zablokowany."
"description": "Profil gracza nie mógł zostać wyświetlony ze względu na aktywne działania moderatora."
}
}
},
@ -104,7 +109,7 @@
},
"blacklist": {
"title": "Nie można wyświetlić rekordu",
"description": "Rekord został zablokowany."
"description": "Nie udało się wyświetlić tego rekordu ze względu na aktywne działania moderatora."
}
},
"station": {
@ -143,7 +148,25 @@
"leaderboard": "Tablica wyników",
"active_players": "Aktywni gracze",
"info": "INFO",
"admin": "ADMIN"
"admin": "ADMIN",
"logged": "Zalogowano jako {{username}}",
"logout": "Wyloguj"
},
"admin": {}
"admin": {
"clear": {
"modal": {
"title": "Czy jesteś pewien?",
"description": "Ta akcja permanentnie wyczyści wszystkie statystyki gracza."
},
"alert": "Wyczyszczono statystyki gracza."
},
"hide": {
"modal": {
"title": "Czy jesteś pewien?",
"description": "Ta akcja ukryje profil gracza."
},
"alert": "Ukryto profil gracza."
}
}
}

View File

@ -15,11 +15,11 @@
*/
import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { TStatsResponse } from "../types/stats.ts";
import { WarningAlert } from "../components/mini/alerts/Warning.tsx";
import { CardDataStats } from "../components/mini/util/CardDataStats.tsx";
import { fetcher } from "../util/fetcher.ts";
import { get } from "../util/fetcher.ts";
import useSWR from 'swr';
import { LoadError } from "../components/mini/loaders/ContentLoader.tsx";
@ -27,7 +27,15 @@ export const Home = () =>
{
const { t } = useTranslation();
const { data, error } = useSWR<TStatsResponse>("/stats/", fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
const { data, error } = useSWR<TStatsResponse>("/stats/", get, { refreshInterval: 10_000, errorRetryCount: 5 });
const [searchParams, setSearchParams] = useSearchParams();
if (searchParams.get('admin_token')) {
window.localStorage.setItem('auth_token', searchParams.get('admin_token')!);
setSearchParams(new URLSearchParams());
setTimeout(() => window.location.reload(), 1000);
}
return (
<>

View File

@ -19,7 +19,7 @@ import { ChangeEvent, useEffect, useState } from "react";
import { useDebounce } from "use-debounce";
import { Search } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import { fetcher } from "../../util/fetcher.ts";
import { 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";
@ -30,7 +30,7 @@ export const ActiveStationsPlayers = () =>
{
const [ params, setParams ] = useState(new URLSearchParams());
const { data, error, isLoading } = useSWR(`/active/station/?${ params.toString() }`, fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
const { data, error, isLoading } = useSWR(`/active/station/?${ params.toString() }`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchParams, setSearchParams ] = useSearchParams();
const [ searchItem, setSearchItem ] = useState(searchParams.get("q") ?? "");

View File

@ -20,7 +20,7 @@ import { useDebounce } from "use-debounce";
import { Search } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { fetcher } from "../../util/fetcher.ts";
import { 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";
@ -30,7 +30,7 @@ export const ActiveTrainPlayers = () =>
{
const [ params, setParams ] = useState(new URLSearchParams());
const { data, error, isLoading } = useSWR(`/active/train/?${ params.toString() }`, fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
const { data, error, isLoading } = useSWR(`/active/train/?${ params.toString() }`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchParams, setSearchParams ] = useSearchParams();

View File

@ -0,0 +1,57 @@
/*
* 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";
export const NotFoundError = () =>
{
const { t } = useTranslation();
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">
<div className="mt-4">
<h3 className="mb-1.5 text-2xl font-semibold text-black dark:text-white">
{ t("notfound.title") }
</h3>
<p className="font-medium">{ t("notfound.description") }</p>
<div className="p-4 md:p-6 xl:p-9 flex gap-2 justify-center">
<Link
to="/"
className="inline-flex items-center justify-center rounded-md bg-primary py-2 px-8 text-center font-medium text-white hover:bg-opacity-90 lg:px-8 xl:px-10"
>
{ t("notfound.button") }
</Link>
</div>
</div>
</div>
</div>
</div>
</>
);
};

View File

@ -20,7 +20,7 @@ import { StationTable } from "../../components/pages/leaderboard/StationTable.ts
import { useDebounce } from "use-debounce";
import { Search } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import { fetcher } from "../../util/fetcher.ts";
import { 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";
@ -30,7 +30,7 @@ export const StationLeaderboard = () =>
{
const [ params, setParams ] = useState(new URLSearchParams());
const { data, error, isLoading } = useSWR(`/leaderboard/station/?${params.toString()}`, fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
const { data, error, isLoading } = useSWR(`/leaderboard/station/?${params.toString()}`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchParams, setSearchParams ] = useSearchParams();

View File

@ -21,7 +21,7 @@ import { useDebounce } from "use-debounce";
import { Search } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { fetcher } from "../../util/fetcher.ts";
import { 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";
@ -30,7 +30,7 @@ export const TrainLeaderboard = () =>
{
const [ params, setParams ] = useState(new URLSearchParams());
const { data, error, isLoading } = useSWR(`/leaderboard/train/?${ params.toString() }`, fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
const { data, error, isLoading } = useSWR(`/leaderboard/train/?${ params.toString() }`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
const [ searchParams, setSearchParams ] = useSearchParams();

View File

@ -21,13 +21,13 @@ import { useTranslation } from "react-i18next";
import { StationLog } from "../../components/pages/log/StationLog.tsx";
import { TrainLog } from "../../components/pages/log/TrainLog.tsx";
import { PageMeta } from "../../components/mini/util/PageMeta.tsx";
import { fetcher } from "../../util/fetcher.ts";
import { get } from "../../util/fetcher.ts";
import useSWR from "swr";
export const Log = () =>
{
const { id } = useParams();
const { data, error, isLoading } = useSWR(`/log/${ id }`, fetcher, { refreshInterval: 30_000, errorRetryCount: 5 });
const { data, error, isLoading } = useSWR(`/log/${ id }`, get, { refreshInterval: 30_000, errorRetryCount: 5 });
const { t } = useTranslation();

View File

@ -20,7 +20,7 @@ import { useDebounce } from "use-debounce";
import { Search } from "../../components/mini/util/Search.tsx";
import { useSearchParams } from "react-router-dom";
import useSWR from 'swr';
import { fetcher } from "../../util/fetcher.ts";
import { 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';
@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next';
export const StationLogs = () =>
{
const [params, setParams] = useState(new URLSearchParams());
const { data, error, isLoading } = useSWR(`/stations/?${params.toString()}`, fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
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") ?? "");

View File

@ -22,13 +22,13 @@ import { useSearchParams } from "react-router-dom";
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
import { useTranslation } from "react-i18next";
import { fetcher } from "../../util/fetcher.ts";
import { get } from "../../util/fetcher.ts";
import useSWR from "swr";
export const TrainLogs = () =>
{
const [ params, setParams ] = useState(new URLSearchParams());
const { data, error, isLoading } = useSWR(`/trains/?${ params.toString() }`, fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
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") ?? "");

View File

@ -24,13 +24,13 @@ import { useTranslation } from "react-i18next";
import { PageMeta } from "../../components/mini/util/PageMeta.tsx";
import { formatTime } from "../../util/time.ts";
import useSWR from 'swr';
import { fetcher } from "../../util/fetcher.ts";
import { get } from "../../util/fetcher.ts";
export const Profile = () =>
{
const { id } = useParams();
const { data, error, isLoading } = useSWR(`/profiles/${ id }`, fetcher, { refreshInterval: 10_000, errorRetryCount: 5 });
const { data, error, isLoading } = useSWR(`/profiles/${ id }`, get, { refreshInterval: 10_000, errorRetryCount: 5 });
const { t } = useTranslation();
@ -40,6 +40,11 @@ export const Profile = () =>
{ 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."/> }

View File

@ -14,4 +14,6 @@
* See LICENSE for more.
*/
export const fetcher = (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(2500) }).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());