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) => [
|
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 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({
|
s && filter.push({
|
||||||
$match: {
|
$match: {
|
||||||
@ -74,7 +80,13 @@ export class LeaderboardRoute
|
|||||||
{
|
{
|
||||||
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
|
const s = req.query.q?.toString().split(",").map(x => new RegExp(escapeRegexString(x), "i"));
|
||||||
|
|
||||||
const filter: PipelineStage[] = [];
|
const filter: PipelineStage[] = [
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
flags: { $nin: ["hidden"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
s && filter.push({
|
s && filter.push({
|
||||||
$match: {
|
$match: {
|
||||||
$and: [
|
$and: [
|
||||||
|
@ -38,7 +38,11 @@ export class LogRoute
|
|||||||
return;
|
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)
|
if (!log)
|
||||||
{
|
{
|
||||||
@ -48,8 +52,16 @@ export class LogRoute
|
|||||||
return;
|
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({
|
res.status(200).json(new SuccessResponseBuilder().setCode(200).setData({
|
||||||
...log.toJSON()
|
...log,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -40,10 +40,10 @@ export class ProfilesRoute
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (player.flags.includes('blacklist'))
|
if (player.flags.includes('hidden'))
|
||||||
{
|
{
|
||||||
res.status(403).json(new ErrorResponseBuilder()
|
res.status(403).json(new ErrorResponseBuilder()
|
||||||
.setCode(403).setData("Profile blacklisted!"));
|
.setCode(403).setData("Profile blocked!"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,6 +23,7 @@ import cors from "cors";
|
|||||||
import { StatsRoute } from "./routes/stats.js";
|
import { StatsRoute } from "./routes/stats.js";
|
||||||
import { LogRoute } from "./routes/log.js";
|
import { LogRoute } from "./routes/log.js";
|
||||||
import { ActivePlayersRoute } from "./routes/activePlayer.js";
|
import { ActivePlayersRoute } from "./routes/activePlayer.js";
|
||||||
|
import { AdminRoute } from "./routes/admin.js";
|
||||||
|
|
||||||
export class ApiModule
|
export class ApiModule
|
||||||
{
|
{
|
||||||
@ -37,6 +38,7 @@ export class ApiModule
|
|||||||
router.use("/profiles/", ProfilesRoute.load());
|
router.use("/profiles/", ProfilesRoute.load());
|
||||||
router.use("/leaderboard/", LeaderboardRoute.load());
|
router.use("/leaderboard/", LeaderboardRoute.load());
|
||||||
router.use("/active/", ActivePlayersRoute.load());
|
router.use("/active/", ActivePlayersRoute.load());
|
||||||
|
router.use("/admin/", AdminRoute.load());
|
||||||
|
|
||||||
router.use("/stats/", StatsRoute.load());
|
router.use("/stats/", StatsRoute.load());
|
||||||
router.use("/log/", LogRoute.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 { PageMeta } from "./components/mini/util/PageMeta.tsx";
|
||||||
import { ActiveStationsPlayers } from "./pages/activePlayers/ActiveStationsPlayers.tsx";
|
import { ActiveStationsPlayers } from "./pages/activePlayers/ActiveStationsPlayers.tsx";
|
||||||
import { ActiveTrainPlayers } from "./pages/activePlayers/ActiveTrainPlayers.tsx";
|
import { ActiveTrainPlayers } from "./pages/activePlayers/ActiveTrainPlayers.tsx";
|
||||||
|
import { AuthProvider } from "./hooks/useAuth.tsx";
|
||||||
|
import { NotFoundError } from "./pages/errors/NotFound.tsx";
|
||||||
|
|
||||||
function App()
|
function App()
|
||||||
{
|
{
|
||||||
@ -52,8 +54,7 @@ function App()
|
|||||||
|
|
||||||
|
|
||||||
return <HelmetProvider>
|
return <HelmetProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
|
||||||
{ loading ? (
|
{ loading ? (
|
||||||
<Loader/>
|
<Loader/>
|
||||||
) : (
|
) : (
|
||||||
@ -170,10 +171,20 @@ function App()
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={
|
||||||
|
<>
|
||||||
|
<NotFoundError/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
</>
|
</>
|
||||||
) }
|
) }
|
||||||
|
</AuthProvider>
|
||||||
</HelmetProvider>;
|
</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 { ArrowIcon } from "../icons/ArrowIcon.tsx";
|
||||||
import { FaHome, FaClipboardList } from "react-icons/fa";
|
import { FaHome, FaClipboardList } from "react-icons/fa";
|
||||||
import { FaChartSimple, FaTrain, FaBuildingFlag, FaBolt } from "react-icons/fa6";
|
import { FaChartSimple, FaTrain, FaBuildingFlag, FaBolt } from "react-icons/fa6";
|
||||||
|
import { useAuth } from "../../../hooks/useAuth.tsx";
|
||||||
|
|
||||||
interface SidebarProps
|
interface SidebarProps
|
||||||
{
|
{
|
||||||
@ -95,6 +96,8 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
|
|||||||
}
|
}
|
||||||
}, [ sidebarExpanded ]);
|
}, [ sidebarExpanded ]);
|
||||||
|
|
||||||
|
const { isAdmin, username } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
ref={ sidebar }
|
ref={ sidebar }
|
||||||
@ -337,6 +340,23 @@ export const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) =>
|
|||||||
} }
|
} }
|
||||||
</SidebarLinkGroup>
|
</SidebarLinkGroup>
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* TODO: add admin panel with simple auth */ }
|
{/* 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">
|
<div className="hidden items-center justify-center p-2.5 sm:flex xl:p-5">
|
||||||
<Link
|
<Link
|
||||||
to={ "/profile/" + station.steam }
|
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") }
|
{ t("active.profile") }
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -20,6 +20,11 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { ArrowIcon, FlexArrowIcon } from "../../mini/icons/ArrowIcon.tsx";
|
import { ArrowIcon, FlexArrowIcon } from "../../mini/icons/ArrowIcon.tsx";
|
||||||
import { formatTime } from "../../../util/time.ts";
|
import { formatTime } from "../../../util/time.ts";
|
||||||
import { FaCheck } from "react-icons/fa6";
|
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 }) =>
|
export const ProfileCard = ({ data }: { data: TProfileData }) =>
|
||||||
{
|
{
|
||||||
@ -27,9 +32,47 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
|
|||||||
const [ showTrains, setShowTrains ] = useState(false);
|
const [ showTrains, setShowTrains ] = useState(false);
|
||||||
const [ showStations, setShowStations ] = useState(false);
|
const [ showStations, setShowStations ] = useState(false);
|
||||||
const [ sortTrainsBy, setSortTrainsBy ] = useState<"time" | "score" | "distance">("distance");
|
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();
|
const { t } = useTranslation();
|
||||||
return <div
|
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">
|
className="overflow-hidden rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
|
||||||
<div className="px-4 pt-6 text-center lg:pb-8 xl:pb-11.5">
|
<div className="px-4 pt-6 text-center lg:pb-8 xl:pb-11.5">
|
||||||
<div
|
<div
|
||||||
@ -40,7 +83,7 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<h3 className="mb-1.5 text-2xl font-semibold text-black dark:text-white">
|
<h3 className="mb-1.5 text-2xl font-semibold text-black dark:text-white">
|
||||||
{ data.player.username } { data.player.flags.includes('verified') &&
|
{ data.player.username } { data.player.flags.includes("verified") &&
|
||||||
<FaCheck className={ "inline text-meta-3 ml-1" }/> }
|
<FaCheck className={ "inline text-meta-3 ml-1" }/> }
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@ -63,6 +106,7 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ Object.keys(data.player.trainStats || {}).length > 0 &&
|
{ 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="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) }>
|
<div className="group relative cursor-pointer" onClick={ () => setShowTrains(val => !val) }>
|
||||||
@ -176,5 +220,28 @@ export const ProfileCard = ({ data }: { data: TProfileData }) =>
|
|||||||
</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">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>
|
||||||
|
</>;
|
||||||
};
|
};
|
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
|
try
|
||||||
{
|
{
|
||||||
const item = window.localStorage.getItem(key);
|
const item = window.localStorage.getItem(key);
|
||||||
|
if (item) {
|
||||||
|
try {
|
||||||
return item ? JSON.parse(item) : initialValue;
|
return item ? JSON.parse(item) : initialValue;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return item ? item : initialValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error)
|
} catch (error)
|
||||||
{
|
{
|
||||||
console.log(error);
|
|
||||||
return initialValue;
|
return initialValue;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -28,6 +28,11 @@
|
|||||||
"author": "Created by <anchor>{{author}}</anchor> with ❤️ for the Simrail community"
|
"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": {
|
"leaderboard": {
|
||||||
"user": "Player",
|
"user": "Player",
|
||||||
"time": "Time",
|
"time": "Time",
|
||||||
@ -92,7 +97,7 @@
|
|||||||
},
|
},
|
||||||
"blacklist": {
|
"blacklist": {
|
||||||
"title": "Unable to display profile",
|
"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": {
|
"blacklist": {
|
||||||
"title": "The record cannot be displayed",
|
"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": {
|
"station": {
|
||||||
@ -143,7 +148,25 @@
|
|||||||
"leaderboard": "Leaderboard",
|
"leaderboard": "Leaderboard",
|
||||||
"active_players": "Active Players",
|
"active_players": "Active Players",
|
||||||
"info": "INFO",
|
"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"
|
"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": {
|
"leaderboard": {
|
||||||
"user": "Gracz",
|
"user": "Gracz",
|
||||||
"time": "Czas",
|
"time": "Czas",
|
||||||
@ -92,7 +97,7 @@
|
|||||||
},
|
},
|
||||||
"blacklist": {
|
"blacklist": {
|
||||||
"title": "Nie można wyświetlić profilu",
|
"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": {
|
"blacklist": {
|
||||||
"title": "Nie można wyświetlić rekordu",
|
"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": {
|
"station": {
|
||||||
@ -143,7 +148,25 @@
|
|||||||
"leaderboard": "Tablica wyników",
|
"leaderboard": "Tablica wyników",
|
||||||
"active_players": "Aktywni gracze",
|
"active_players": "Aktywni gracze",
|
||||||
"info": "INFO",
|
"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 { 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 { TStatsResponse } from "../types/stats.ts";
|
||||||
import { WarningAlert } from "../components/mini/alerts/Warning.tsx";
|
import { WarningAlert } from "../components/mini/alerts/Warning.tsx";
|
||||||
import { CardDataStats } from "../components/mini/util/CardDataStats.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 useSWR from 'swr';
|
||||||
import { LoadError } from "../components/mini/loaders/ContentLoader.tsx";
|
import { LoadError } from "../components/mini/loaders/ContentLoader.tsx";
|
||||||
|
|
||||||
@ -27,7 +27,15 @@ export const Home = () =>
|
|||||||
{
|
{
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -19,7 +19,7 @@ import { ChangeEvent, useEffect, useState } from "react";
|
|||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import { Search } from "../../components/mini/util/Search.tsx";
|
import { Search } from "../../components/mini/util/Search.tsx";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { fetcher } from "../../util/fetcher.ts";
|
import { get } from "../../util/fetcher.ts";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
|
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
|
||||||
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
|
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
|
||||||
@ -30,7 +30,7 @@ export const ActiveStationsPlayers = () =>
|
|||||||
{
|
{
|
||||||
const [ params, setParams ] = useState(new URLSearchParams());
|
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 [ searchParams, setSearchParams ] = useSearchParams();
|
||||||
const [ searchItem, setSearchItem ] = useState(searchParams.get("q") ?? "");
|
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 { Search } from "../../components/mini/util/Search.tsx";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { fetcher } from "../../util/fetcher.ts";
|
import { get } from "../../util/fetcher.ts";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
|
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
|
||||||
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
|
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
|
||||||
@ -30,7 +30,7 @@ export const ActiveTrainPlayers = () =>
|
|||||||
{
|
{
|
||||||
const [ params, setParams ] = useState(new URLSearchParams());
|
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();
|
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 { useDebounce } from "use-debounce";
|
||||||
import { Search } from "../../components/mini/util/Search.tsx";
|
import { Search } from "../../components/mini/util/Search.tsx";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { fetcher } from "../../util/fetcher.ts";
|
import { get } from "../../util/fetcher.ts";
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
|
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
|
||||||
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
|
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
|
||||||
@ -30,7 +30,7 @@ export const StationLeaderboard = () =>
|
|||||||
{
|
{
|
||||||
const [ params, setParams ] = useState(new URLSearchParams());
|
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();
|
const [ searchParams, setSearchParams ] = useSearchParams();
|
||||||
|
@ -21,7 +21,7 @@ import { useDebounce } from "use-debounce";
|
|||||||
import { Search } from "../../components/mini/util/Search.tsx";
|
import { Search } from "../../components/mini/util/Search.tsx";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { fetcher } from "../../util/fetcher.ts";
|
import { get } from "../../util/fetcher.ts";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
|
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
|
||||||
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
|
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
|
||||||
@ -30,7 +30,7 @@ export const TrainLeaderboard = () =>
|
|||||||
{
|
{
|
||||||
const [ params, setParams ] = useState(new URLSearchParams());
|
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();
|
const [ searchParams, setSearchParams ] = useSearchParams();
|
||||||
|
@ -21,13 +21,13 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { StationLog } from "../../components/pages/log/StationLog.tsx";
|
import { StationLog } from "../../components/pages/log/StationLog.tsx";
|
||||||
import { TrainLog } from "../../components/pages/log/TrainLog.tsx";
|
import { TrainLog } from "../../components/pages/log/TrainLog.tsx";
|
||||||
import { PageMeta } from "../../components/mini/util/PageMeta.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";
|
import useSWR from "swr";
|
||||||
|
|
||||||
export const Log = () =>
|
export const Log = () =>
|
||||||
{
|
{
|
||||||
const { id } = useParams();
|
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();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
@ -20,7 +20,7 @@ import { useDebounce } from "use-debounce";
|
|||||||
import { Search } from "../../components/mini/util/Search.tsx";
|
import { Search } from "../../components/mini/util/Search.tsx";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { fetcher } from "../../util/fetcher.ts";
|
import { get } from "../../util/fetcher.ts";
|
||||||
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
|
import { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
|
||||||
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
|
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
export const StationLogs = () =>
|
export const StationLogs = () =>
|
||||||
{
|
{
|
||||||
const [params, setParams] = useState(new URLSearchParams());
|
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 [ searchParams, setSearchParams ] = useSearchParams();
|
||||||
const [ searchItem, setSearchItem ] = useState(searchParams.get("q") ?? "");
|
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 { WarningAlert } from "../../components/mini/alerts/Warning.tsx";
|
||||||
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
|
import { ContentLoader, LoadError } from "../../components/mini/loaders/ContentLoader.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { fetcher } from "../../util/fetcher.ts";
|
import { get } from "../../util/fetcher.ts";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
export const TrainLogs = () =>
|
export const TrainLogs = () =>
|
||||||
{
|
{
|
||||||
const [ params, setParams ] = useState(new URLSearchParams());
|
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 [ searchParams, setSearchParams ] = useSearchParams();
|
||||||
const [ searchItem, setSearchItem ] = useState(searchParams.get("q") ?? "");
|
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 { PageMeta } from "../../components/mini/util/PageMeta.tsx";
|
||||||
import { formatTime } from "../../util/time.ts";
|
import { formatTime } from "../../util/time.ts";
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { fetcher } from "../../util/fetcher.ts";
|
import { get } from "../../util/fetcher.ts";
|
||||||
|
|
||||||
|
|
||||||
export const Profile = () =>
|
export const Profile = () =>
|
||||||
{
|
{
|
||||||
const { id } = useParams();
|
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();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@ -40,6 +40,11 @@ export const Profile = () =>
|
|||||||
{ isLoading && <ContentLoader/> }
|
{ isLoading && <ContentLoader/> }
|
||||||
{/* ERROR */}
|
{/* ERROR */}
|
||||||
{ error && <LoadError /> }
|
{ 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 */ }
|
{/* NOT FOUND */ }
|
||||||
{ data && data.code === 404 && <PageMeta title="simrail.pro | Profile not found"
|
{ data && data.code === 404 && <PageMeta title="simrail.pro | Profile not found"
|
||||||
description="Player's profile could not be found or the player has a private Steam profile."/> }
|
description="Player's profile could not be found or the player has a private Steam profile."/> }
|
||||||
|
@ -14,4 +14,6 @@
|
|||||||
* See LICENSE for more.
|
* 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