forked from simrail/simrail.pro
feat(frontend, backend): moderation panel
This commit is contained in:
parent
daed28fa91
commit
0d949833d6
163
packages/backend/src/http/routes/admin.ts
Normal file
163
packages/backend/src/http/routes/admin.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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: [
|
||||
|
@ -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,
|
||||
}));
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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());
|
||||
|
40
packages/backend/src/mongo/admin.ts
Normal file
40
packages/backend/src/mongo/admin.ts
Normal 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
|
||||
}
|
@ -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>;
|
||||
}
|
||||
|
||||
|
61
packages/frontend/src/components/mini/modal/ConfirmModal.tsx
Normal file
61
packages/frontend/src/components/mini/modal/ConfirmModal.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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 */ }
|
||||
|
@ -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>
|
||||
|
@ -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>;
|
||||
</>;
|
||||
};
|
31
packages/frontend/src/hooks/useAuth.tsx
Normal file
31
packages/frontend/src/hooks/useAuth.tsx
Normal 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);
|
||||
};
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
@ -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 (
|
||||
<>
|
||||
|
@ -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") ?? "");
|
||||
|
@ -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();
|
||||
|
57
packages/frontend/src/pages/errors/NotFound.tsx
Normal file
57
packages/frontend/src/pages/errors/NotFound.tsx
Normal 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>
|
||||
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
@ -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();
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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") ?? "");
|
||||
|
@ -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") ?? "");
|
||||
|
@ -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."/> }
|
||||
|
@ -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());
|
Loading…
x
Reference in New Issue
Block a user