279 lines
14 KiB
TypeScript
279 lines
14 KiB
TypeScript
/*
|
|
* Copyright (C) 2025 Aleksander <alekswilc> Wilczyński (aleks@alekswilc.dev)
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* See LICENSE for more.
|
|
*/
|
|
|
|
import { useState } from "react";
|
|
import { TProfileData, TProfilePlayer } from "../../../types/profile.ts";
|
|
import { useTranslation } from "react-i18next";
|
|
import { formatTime } from "../../../util/time.ts";
|
|
import { useAuth } from "../../../hooks/useAuth.tsx";
|
|
import { ConfirmModal } from "../../mini/modal/ConfirmModal.tsx";
|
|
import { post } from "../../../util/fetcher.ts";
|
|
import { toast } from "react-toastify";
|
|
import dayjs from "dayjs";
|
|
import { UserIcons } from "../../mini/icons/UserIcons.tsx";
|
|
import { StationStat } from '../../mini/profile/StationStat.tsx';
|
|
import { chunk } from '../../../util/chunk.ts';
|
|
import { Paginator } from '../../mini/util/Paginator.tsx';
|
|
import { TrainStat } from '../../mini/profile/TrainStat.tsx';
|
|
import { TImagesData } from '../../../types/images.ts';
|
|
|
|
const sortTrainsByList: Record<number, string> = {
|
|
[0]: 'time',
|
|
[1]: 'score',
|
|
[2]: 'distance',
|
|
}
|
|
|
|
export const ProfileCard = ({ data, images }: { data: TProfileData, images: TImagesData }) => {
|
|
const [sortTrainsBy, setSortTrainsBy] = useState(0);
|
|
const [sortTrainsBy2, setSortTrainsBy2] = useState(2);
|
|
const [sortStationsBy, setSortStationsBy] = useState(0);
|
|
const [hideLeaderboardStatsModal, setHideLeaderboardStatsModal] = useState(false);
|
|
const [hideProfileModal, setHideProfileModal] = useState(false);
|
|
|
|
const { isAdmin, token } = useAuth();
|
|
|
|
// #region ADMIN
|
|
const adminToggleHideLeaderboardPlayerProfile = () => {
|
|
post(`/admin/profile/${data.player.id}/${data.player.flags.includes("leaderboard_hidden") ? "showLeaderboard" : "hideLeaderboard"}`, {}, { "X-Auth-Token": token })
|
|
.then((response) => {
|
|
if (response.code === 200) {
|
|
toast.success(t("admin.hideLeaderboard.alert"));
|
|
}
|
|
});
|
|
};
|
|
|
|
const adminHidePlayerProfile = () => {
|
|
post(`/admin/profile/${data.player.id}/hide`, {}, { "X-Auth-Token": token })
|
|
.then((response) => {
|
|
if (response.code === 200) {
|
|
toast.success(t("admin.hide.alert"));
|
|
}
|
|
});
|
|
};
|
|
|
|
const adminForceUpdate = () => {
|
|
post(`/admin/profile/${data.player.id}/forceUpdate`, {}, { "X-Auth-Token": token })
|
|
.then((response) => {
|
|
if (response.code === 200) {
|
|
toast.success(t("admin.update.alert"));
|
|
}
|
|
});
|
|
};
|
|
// #endregion
|
|
|
|
|
|
const sortStations = (a: keyof TProfilePlayer['dispatcherStats'], b: keyof TProfilePlayer['dispatcherStats']) => {
|
|
if (sortStationsBy) {
|
|
const _a = a;
|
|
a = b;
|
|
b = _a;
|
|
}
|
|
|
|
return data.player.dispatcherStats[b].time - data.player.dispatcherStats[a].time
|
|
}
|
|
|
|
const sortTrains = (a: keyof TProfilePlayer['trainStats'], b: keyof TProfilePlayer['trainStats']) => {
|
|
if (sortTrainsBy2) {
|
|
const _a = a;
|
|
a = b;
|
|
b = _a;
|
|
}
|
|
|
|
return data.player.trainStats[b][(sortTrainsByList[sortTrainsBy] ?? 'distance') as 'distance'] - data.player.trainStats[a][(sortTrainsByList[sortTrainsBy] ?? 'distance') as 'distance'];
|
|
}
|
|
|
|
const dispatcherStats = [...chunk(Object.keys(data.player.dispatcherStats), 8)];
|
|
const [dispatcherPage, setDispatcherPage] = useState(1);
|
|
|
|
const trainStats = [...chunk(Object.keys(data.player.trainStats), 8)];
|
|
const [trainPage, setTrainPage] = useState(1);
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
|
return <>
|
|
<ConfirmModal showModal={hideLeaderboardStatsModal} setShowModal={setHideLeaderboardStatsModal}
|
|
onConfirm={adminToggleHideLeaderboardPlayerProfile}
|
|
title={t("admin.hideLeaderboard.modal.title")}
|
|
description={t("admin.hideLeaderboard.modal.description")} />
|
|
<ConfirmModal showModal={hideProfileModal} setShowModal={setHideProfileModal}
|
|
onConfirm={adminHidePlayerProfile} title={t("admin.hide.modal.title")}
|
|
description={t("admin.hide.modal.description")} />
|
|
<div
|
|
className="overflow-hidden ">
|
|
<div className="px-4 pt-6 text-center lg:pb-8 xl:pb-11.5">
|
|
<div
|
|
className="mx-auto max-w-44 rounded-full">
|
|
<div className="relative rounded-full">
|
|
<img className="rounded-full" src={data.player.avatar} alt="profile" />
|
|
{data.active &&
|
|
<span className="absolute w-full rounded-full border-white bg-[#219653] dark:border-black max-w-5.5 right-0 top-0 h-5.5 border-[3px]"></span>}
|
|
</div>
|
|
</div>
|
|
<div className="mt-4">
|
|
<h3 className="text-2xl font-semibold text-black dark:text-white">
|
|
{data.player.username} <UserIcons flags={data.player.flags} />
|
|
</h3>
|
|
|
|
<div
|
|
className="mx-auto mt-4.5 mb-5.5 grid max-w-94 grid-cols-2 rounded-md border border-stroke py-2.5 shadow-1 dark:border-strokedark dark:bg-[#37404F]">
|
|
<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>
|
|
|
|
{data.active && data.active.type === "train" &&
|
|
<div className="mx-auto text-center">
|
|
<h4 className="font-semibold text-black dark:text-white">{t("profile.active.train", { train: `${data.active.trainName} - ${data.active.trainNumber}`, server: data.active.server.toUpperCase() })}</h4>
|
|
</div>}
|
|
|
|
{data.active && data.active.type === "station" &&
|
|
<div className="mx-auto text-center">
|
|
<h4 className="font-semibold text-black dark:text-white">{t("profile.active.station", { station: `${data.active.stationName} - ${data.active.stationShort}`, server: data.active.server.toUpperCase() })}</h4>
|
|
</div>}
|
|
</div>
|
|
|
|
<div className="px-5 pt-6 pb-5 sm:px-7.5 rounded-md">
|
|
<h1 className="text-xl text-black dark:text-white pb-5">{t("profile.stations.header")}</h1>
|
|
<div className="flex flex-row gap-4">
|
|
<a className='cursor-pointer' onClick={() => setSortStationsBy((prev) => prev === 0 ? 1 : 0)}><p className='text-base'>
|
|
<strong>{t('profile.stations.sortby.title')}</strong> {sortStationsBy === 0 ? t('profile.stations.sortby.max') : t('profile.stations.sortby.min')}
|
|
</p></a>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4 pt-4">
|
|
{dispatcherStats[dispatcherPage - 1].sort(sortStations).map(stationName => {
|
|
const station = data.player.dispatcherStats[stationName];
|
|
|
|
return <StationStat stationName={stationName} time={station.time} image={images.stations[stationName]} />
|
|
})}
|
|
</div>
|
|
<div className="flex flex-col pt-4">
|
|
<Paginator setPage={setDispatcherPage} page={dispatcherPage} pages={dispatcherStats.length} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-5 pt-6 pb-5 sm:px-7.5 rounded-md">
|
|
<h1 className="text-xl text-black dark:text-white pb-5">{t("profile.trains.header")}</h1>
|
|
<div className="flex flex-col">
|
|
<a className='cursor-pointer' onClick={() => setSortTrainsBy2((prev) => prev === 0 ? 1 : 0)}><p className='text-base'>
|
|
<strong>{t('profile.trains.sortby.title')}</strong> {sortTrainsBy2 === 0 ? t('profile.trains.sortby.max') : t('profile.trains.sortby.min')}
|
|
</p></a>
|
|
<a className='cursor-pointer' onClick={() => setSortTrainsBy((prev) => {
|
|
prev++;
|
|
if (prev > 2) prev = 0;
|
|
return prev;
|
|
})}><p className='text-base'>
|
|
<strong>{t('profile.trains.sortby.title')}</strong> {t('profile.trains.sortby.' + sortTrainsByList[sortTrainsBy])}
|
|
</p></a>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-7.5 sm:grid-cols-3 xl:grid-cols-4 pt-4">
|
|
{trainStats[trainPage - 1].sort(sortTrains).map(trainName => {
|
|
const train = data.player.trainStats[trainName];
|
|
return <TrainStat trainName={trainName} time={train.time} distance={train.distance} score={train.score} image={images.trains[trainName]} />
|
|
})}
|
|
</div>
|
|
<div className="flex flex-col pt-4">
|
|
<Paginator setPage={setTrainPage} page={trainPage} pages={trainStats.length} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* <div className="bg-white px-5 pt-6 pb-5 shadow-default dark:bg-boxdark sm:px-7.5">
|
|
|
|
<div className="flex flex-col rounded-sm border border-stroke dark:border-strokedark">
|
|
<div className="grid grid-cols-2 rounded-sm bg-gray-2 dark:bg-meta-4">
|
|
<div className="p-2.5 text-center xl:p-5">
|
|
<h5 className="text-sm font-medium uppercase xsm:text-base">
|
|
{t("profile.stations.station")}
|
|
</h5>
|
|
</div>
|
|
|
|
<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 items-center justify-center p-2.5 flex flex-col xl:p-5 gap-2">
|
|
<h1 className="text-xl text-black dark:text-white">{t("admin.header")}</h1>
|
|
|
|
<div className="items-center justify-center p-2.5 flex xl:p-5 gap-2 flex-wrap sm:flex-nowrap">
|
|
|
|
{data.player.flags.includes("leaderboard_hidden") ?
|
|
<button className={"inline-flex items-center justify-center rounded-md py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5 bg-success"}
|
|
onClick={() => adminToggleHideLeaderboardPlayerProfile()}>
|
|
{t("admin.hideLeaderboard.button2")}
|
|
</button> :
|
|
<button className={"inline-flex items-center justify-center rounded-md py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5 bg-danger"}
|
|
onClick={() => setHideLeaderboardStatsModal(true)}>
|
|
{t("admin.hideLeaderboard.button")}
|
|
</button>}
|
|
|
|
<button className="inline-flex items-center justify-center rounded-md bg-danger py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5"
|
|
onClick={() => setHideProfileModal(true)}>
|
|
{t("admin.hide.button")}
|
|
</button>
|
|
|
|
<button className="inline-flex items-center justify-center rounded-md bg-primary py-2 px-5 text-center font-medium text-white hover:bg-opacity-50 lg:px-4 xl:px-5"
|
|
onClick={() => adminForceUpdate()}>
|
|
{t("admin.update.button")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>}
|
|
|
|
<div className="items-center justify-center p-2.5 flex flex-col xl:p-5 gap-2">
|
|
<h1 className="text-sm text-black dark:text-white">
|
|
{t("profile.info", { date: dayjs(data.player.createdAt).format("DD/MM/YYYY") })}
|
|
</h1>
|
|
</div>
|
|
|
|
</div>
|
|
</>;
|
|
}; |