mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-09 07:09:57 +03:00
Dashboard page + Force Email Verification.
This commit is contained in:
@@ -143,7 +143,7 @@ export default function RegisterPage() {
|
||||
</a>
|
||||
</h4>
|
||||
|
||||
<h6>(Opens in a new tab.)</h6>
|
||||
<h6>(This link opens in a new tab.)</h6>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import useSetSubheader from "components/layout/header/useSetSubheader";
|
||||
import SessionCard from "components/sessions/SessionCard";
|
||||
import ApiError from "components/util/ApiError";
|
||||
import AsyncLoader from "components/util/AsyncLoader";
|
||||
import Divider from "components/util/Divider";
|
||||
import LinkButton from "components/util/LinkButton";
|
||||
import SplashText from "components/util/SplashText";
|
||||
import Loading from "components/util/Loading";
|
||||
import useApiQuery from "components/util/query/useApiQuery";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import { UserGameStatsContext } from "context/UserGameStatsContext";
|
||||
import { UserSettingsContext } from "context/UserSettingsContext";
|
||||
import { TachiConfig } from "lib/config";
|
||||
import React, { useContext } from "react";
|
||||
import { Alert } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import { PublicUserDocument } from "tachi-common";
|
||||
import { ToCDNURL } from "util/api";
|
||||
import { GetGameConfig, PublicUserDocument } from "tachi-common";
|
||||
import { UGSWithRankingData, UserRecentSummary } from "types/api-returns";
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { NumericSOV } from "util/sorts";
|
||||
import { GameStatContainer } from "./users/UserGamesPage";
|
||||
|
||||
export function DashboardPage() {
|
||||
const { settings } = useContext(UserSettingsContext);
|
||||
@@ -21,17 +29,113 @@ export function DashboardPage() {
|
||||
|
||||
if (!user) {
|
||||
return <DashboardNotLoggedIn />;
|
||||
} else if (user && !ugs) {
|
||||
return <DashboardLoggedInNoScores user={user} />;
|
||||
}
|
||||
|
||||
return <DashboardLoggedIn user={user} />;
|
||||
return (
|
||||
<>
|
||||
{ugs?.length ? (
|
||||
<DashboardLoggedIn user={user} />
|
||||
) : (
|
||||
<DashboardLoggedInNoScores user={user} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardLoggedIn({ user }: { user: PublicUserDocument }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="display-4">Welcome Back, {user.username}.</span>
|
||||
<Divider />
|
||||
<RecentInfo user={user} />
|
||||
<h1>Here's all your profiles.</h1>
|
||||
<Divider />
|
||||
|
||||
<UserGameStatsInfo user={user} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentInfo({ user }: { user: PublicUserDocument }) {
|
||||
const { data, isLoading, error } = useApiQuery<UserRecentSummary>(
|
||||
`/users/${user.id}/recent-summary`
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!data.recentSessions.length) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
style={{
|
||||
backgroundColor: TachiConfig.type === "ktchi" ? "#e61c6e44" : "#527acc44",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
<h1>Today's Summary</h1>
|
||||
You've gotten <b>{data.recentPlaycount}</b> new score
|
||||
{data.recentPlaycount !== 1 ? "s" : ""} today!
|
||||
</div>
|
||||
</Alert>
|
||||
<Divider />
|
||||
<h1>New Sessions</h1>
|
||||
You've had <b>{data.recentSessions.length}</b> session
|
||||
{data.recentSessions.length !== 1 ? "s" : ""} today!
|
||||
<Divider />
|
||||
{data.recentSessions.sort(NumericSOV(x => x.timeEnded, true)).map(e => (
|
||||
<SessionCard sessionID={e.sessionID} key={e.sessionID} />
|
||||
))}
|
||||
<Divider />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserGameStatsInfo({ user }: { user: PublicUserDocument }) {
|
||||
return (
|
||||
<div className="row">
|
||||
<AsyncLoader
|
||||
promiseFn={async () => {
|
||||
const res = await APIFetchV1<UGSWithRankingData[]>(
|
||||
`/users/${user.id}/game-stats`
|
||||
);
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.description);
|
||||
}
|
||||
|
||||
return res.body.sort((a, b) => {
|
||||
if (a.game === b.game) {
|
||||
const gameConfig = GetGameConfig(a.game);
|
||||
|
||||
return (
|
||||
gameConfig.validPlaytypes.indexOf(a.playtype) -
|
||||
gameConfig.validPlaytypes.indexOf(b.playtype)
|
||||
);
|
||||
}
|
||||
|
||||
const i1 = TachiConfig.supportedGames.indexOf(a.game);
|
||||
const i2 = TachiConfig.supportedGames.indexOf(b.game);
|
||||
|
||||
return i1 - i2;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{ugs =>
|
||||
ugs.map(e => (
|
||||
<GameStatContainer ugs={e} reqUser={user} key={`${e.game}:${e.playtype}`} />
|
||||
))
|
||||
}
|
||||
</AsyncLoader>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,7 +180,7 @@ function DashboardNotLoggedIn() {
|
||||
|
||||
function FeatureContainer({ tagline, description }: { tagline: string; description: string }) {
|
||||
return (
|
||||
<div className="row my-4" style={{ lineHeight: "1.3", fontSize: "1.15rem" }}>
|
||||
<div className="row my-4 mb-16" style={{ lineHeight: "1.3", fontSize: "1.15rem" }}>
|
||||
<div className="col-12 col-lg-6">
|
||||
<h1 className="display-4">{tagline}</h1>
|
||||
<span>{description}</span>
|
||||
@@ -86,5 +190,21 @@ function FeatureContainer({ tagline, description }: { tagline: string; descripti
|
||||
}
|
||||
|
||||
function DashboardLoggedInNoScores({ user }: { user: PublicUserDocument }) {
|
||||
return <span>foo</span>;
|
||||
return (
|
||||
<div>
|
||||
<span className="display-4">
|
||||
Welcome to {TachiConfig.name}, {user.username}!
|
||||
</span>
|
||||
<h4 className="mt-4">It looks like you have no scores. Let's get you set up!</h4>
|
||||
<Divider />
|
||||
<h4 style={{ lineHeight: 2 }}>
|
||||
Once you've got some scores imported, we'll analyse your scores.
|
||||
<br />
|
||||
You'll get a profile for that game, and a position on the leaderboards!
|
||||
</h4>
|
||||
<LinkButton className="btn-outline-primary" to="/dashboard/import">
|
||||
Import some scores!
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,49 +77,35 @@ function GamesInfo({ ugs, reqUser }: { ugs: UserGameStats[]; reqUser: PublicUser
|
||||
return <></>;
|
||||
}
|
||||
|
||||
// uses modulo to determine whether we're on the
|
||||
// last row or not
|
||||
// to do sizing like [12] or [6][6] instead of [3][3][3]
|
||||
let mdSize = "6";
|
||||
let lgSize = "4";
|
||||
if (ugs.length - i <= ugs.length % 3 && ugs.length > 3) {
|
||||
lgSize = (12 / (ugs.length % 3)).toString();
|
||||
}
|
||||
|
||||
if (ugs.length - i <= ugs.length % 2 && ugs.length > 2) {
|
||||
mdSize = (12 / (ugs.length % 2)).toString();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`col-12 col-md-${mdSize} col-lg-${lgSize}`}
|
||||
key={`${e.game}:${e.playtype}`}
|
||||
>
|
||||
<GameStatContainer ugs={e} reqUser={reqUser} />
|
||||
</div>
|
||||
);
|
||||
return <GameStatContainer key={`${game}:${playtype}`} ugs={e} reqUser={reqUser} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GameStatContainer({ ugs, reqUser }: { ugs: UGSWithRankingData } & Props) {
|
||||
export function GameStatContainer({ ugs, reqUser }: { ugs: UGSWithRankingData } & Props) {
|
||||
return (
|
||||
<Card
|
||||
className="mb-4"
|
||||
footer={
|
||||
<div className="d-flex justify-content-end">
|
||||
<LinkButton
|
||||
to={`/dashboard/users/${reqUser.username}/games/${ugs.game}/${ugs.playtype}`}
|
||||
>
|
||||
View Game Profile
|
||||
</LinkButton>
|
||||
</div>
|
||||
}
|
||||
header={FormatGame(ugs.game, ugs.playtype)}
|
||||
>
|
||||
<UGPTRatingsTable ugs={ugs} />
|
||||
<RankingData game={ugs.game} playtype={ugs.playtype} rankingData={ugs.__rankingData} />
|
||||
</Card>
|
||||
<div className="col-12 col-lg-6">
|
||||
<Card
|
||||
className="mb-4"
|
||||
footer={
|
||||
<div className="d-flex justify-content-end">
|
||||
<LinkButton
|
||||
to={`/dashboard/users/${reqUser.username}/games/${ugs.game}/${ugs.playtype}`}
|
||||
>
|
||||
View Game Profile
|
||||
</LinkButton>
|
||||
</div>
|
||||
}
|
||||
header={FormatGame(ugs.game, ugs.playtype)}
|
||||
>
|
||||
<UGPTRatingsTable ugs={ugs} />
|
||||
<RankingData
|
||||
game={ugs.game}
|
||||
playtype={ugs.playtype}
|
||||
rankingData={ugs.__rankingData}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ErrorPage } from "app/pages/ErrorPage";
|
||||
import { Layout } from "components/layout/Layout";
|
||||
import React from "react";
|
||||
import EmailVerify from "components/layout/misc/EmailVerify";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Redirect, Route, Switch } from "react-router-dom";
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { DashboardPage } from "../pages/dashboard/DashboardPage";
|
||||
import CreditsPage from "../pages/dashboard/misc/CreditsPage";
|
||||
import GameRoutes from "./GameRoutes";
|
||||
@@ -9,6 +12,32 @@ import ImportRoutes from "./ImportRoutes";
|
||||
import UserRoutes from "./UserRoutes";
|
||||
|
||||
export default function DashboardRoutes() {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
const [hasVerifiedEmail, setHasVerifiedEmail] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return setHasVerifiedEmail(null);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const hasVerified = await APIFetchV1<boolean>(`/users/${user.id}/is-email-verified`);
|
||||
|
||||
if (hasVerified.success) {
|
||||
setHasVerifiedEmail(hasVerified.body);
|
||||
}
|
||||
})();
|
||||
}, [user]);
|
||||
|
||||
if (hasVerifiedEmail === false) {
|
||||
return (
|
||||
<Layout>
|
||||
<EmailVerify setHasVerifiedEmail={setHasVerifiedEmail} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Switch>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import Divider from "components/util/Divider";
|
||||
import Muted from "components/util/Muted";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import React, { useContext, useEffect } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import toast from "react-hot-toast";
|
||||
import { SetState } from "types/react";
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { ONE_MINUTE } from "util/constants/time";
|
||||
import useSetSubheader from "../header/useSetSubheader";
|
||||
|
||||
export default function EmailVerify({
|
||||
setHasVerifiedEmail,
|
||||
}: {
|
||||
setHasVerifiedEmail: SetState<boolean | null>;
|
||||
}) {
|
||||
useSetSubheader("Email Verification...");
|
||||
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
async function CheckEmail() {
|
||||
const r = await APIFetchV1(`/users/${user!.id}/is-email-verified`);
|
||||
|
||||
if (r.success && r.body) {
|
||||
setHasVerifiedEmail(true);
|
||||
}
|
||||
|
||||
return r.success ? r.body : false;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(CheckEmail, ONE_MINUTE);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="col-12 col-lg-6 offset-lg-3 text-center" style={{ fontSize: "1.4rem" }}>
|
||||
One last thing! You need to verify your email before you can use the site.
|
||||
<br />
|
||||
An email has been sent to the address you signed up with.
|
||||
<Divider />
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const res = await CheckEmail();
|
||||
|
||||
if (!res) {
|
||||
toast.error(
|
||||
"Looks like your email isn't verified. If you're having issues, contact us in the discord."
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Yup, verified my email.
|
||||
</Button>
|
||||
<Divider />
|
||||
<a
|
||||
style={{ fontSize: "1.1rem" }}
|
||||
href="#"
|
||||
onClick={async () => {
|
||||
await APIFetchV1("/auth/resend-verify-email", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
toast.success("Sent a new email to your address.");
|
||||
}}
|
||||
>
|
||||
I haven't got it. Re-send the email!
|
||||
</a>
|
||||
<br />
|
||||
<span style={{ fontSize: "1rem" }}>
|
||||
<Muted>
|
||||
Can't get the email? Signed up with a fake email like an idiot? You'll have to
|
||||
contact us at the discord below.
|
||||
</Muted>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import Card from "components/layout/page/Card";
|
||||
import ScoreTable from "components/tables/scores/ScoreTable";
|
||||
import ApiError from "components/util/ApiError";
|
||||
import Divider from "components/util/Divider";
|
||||
import Icon from "components/util/Icon";
|
||||
import LinkButton from "components/util/LinkButton";
|
||||
import Loading from "components/util/Loading";
|
||||
import Muted from "components/util/Muted";
|
||||
import useApiQuery from "components/util/query/useApiQuery";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import React, { useContext, useMemo, useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import toast from "react-hot-toast";
|
||||
import { FormatGame, SessionDocument } from "tachi-common";
|
||||
import { SessionReturns } from "types/api-returns";
|
||||
import { ScoreDataset } from "types/tables";
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { CreateChartMap, CreateSongMap } from "util/data";
|
||||
import { NumericSOV } from "util/sorts";
|
||||
import { FormatDuration } from "util/time";
|
||||
|
||||
export default function SessionCard({ sessionID }: { sessionID: string }) {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
const { data, isLoading, error } = useApiQuery<SessionReturns>(`/sessions/${sessionID}`);
|
||||
|
||||
const [highlight, setHighlight] = useState(false);
|
||||
|
||||
useMemo(() => {
|
||||
setHighlight(data?.session.highlight ?? false);
|
||||
}, [data]);
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const { session, charts, scores, songs, user: sessionUser } = data;
|
||||
|
||||
const songMap = CreateSongMap(songs);
|
||||
const chartMap = CreateChartMap(charts);
|
||||
|
||||
const scoreDataset: ScoreDataset = [];
|
||||
|
||||
for (const score of scores) {
|
||||
scoreDataset.push({
|
||||
...score,
|
||||
__related: {
|
||||
chart: chartMap.get(score.chartID)!,
|
||||
song: songMap.get(score.songID)!,
|
||||
index: 0,
|
||||
user: sessionUser,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
scoreDataset.sort(NumericSOV(x => x.timeAchieved ?? 0, true));
|
||||
|
||||
return (
|
||||
<Card header={session.name}>
|
||||
<SessionOverview session={session} username={sessionUser.username} />
|
||||
<Divider />
|
||||
<ScoreTable
|
||||
pageLen={5}
|
||||
game={session.game}
|
||||
playtype={session.playtype}
|
||||
dataset={scoreDataset}
|
||||
/>
|
||||
<Divider />
|
||||
{sessionUser.id === user?.id && (
|
||||
<div className="d-flex w-100 justify-content-center">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const res = await APIFetchV1(
|
||||
`/sessions/${session.sessionID}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
highlight: !highlight,
|
||||
}),
|
||||
},
|
||||
false,
|
||||
true
|
||||
);
|
||||
|
||||
if (res.success) {
|
||||
if (!highlight) {
|
||||
toast.success("Highlighted Session!");
|
||||
} else {
|
||||
toast.success("Unhighlighted Session.");
|
||||
}
|
||||
|
||||
setHighlight(!highlight);
|
||||
}
|
||||
}}
|
||||
variant={highlight ? "warning" : "outline-warning"}
|
||||
>
|
||||
<Icon type="star" />
|
||||
{highlight ? "Remove as Highlight" : "Highlight this session!"}
|
||||
</Button>
|
||||
<LinkButton
|
||||
to={`/dashboard/users/${sessionUser.username}/games/${session.game}/${session.playtype}/sessions/${session.sessionID}`}
|
||||
className="ml-4 btn-primary"
|
||||
>
|
||||
View Session
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionOverview({ session, username }: { session: SessionDocument; username: string }) {
|
||||
return (
|
||||
<div className="row">
|
||||
<StatIcon name="Scores" value={session.scoreInfo.length} />
|
||||
|
||||
<div className="col-12 col-lg-4">
|
||||
<div className="d-flex justify-content-center align-self-center">
|
||||
<LinkButton
|
||||
to={`/dashboard/users/${username}/games/${session.game}/${session.playtype}/sessions/${session.sessionID}`}
|
||||
className="btn-primary"
|
||||
>
|
||||
View Session
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatIcon
|
||||
name="Duration"
|
||||
value={FormatDuration(session.timeEnded - session.timeStarted)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatIcon({ name, value }: { name: string; value: React.ReactChild }) {
|
||||
return (
|
||||
<div className="col-12 col-lg-4 text-center">
|
||||
<h4>
|
||||
<Muted>{name}</Muted>
|
||||
</h4>
|
||||
<h1>{value}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -164,3 +164,9 @@ export interface UserLeaderboardReturns<I extends IDStrings = IDStrings> {
|
||||
users: PublicUserDocument[];
|
||||
gameStats: UserGameStats<I>[];
|
||||
}
|
||||
|
||||
export interface UserRecentSummary {
|
||||
recentPlaycount: integer;
|
||||
recentSessions: SessionDocument[];
|
||||
recentFolders: FolderDocument[];
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export const loggedInSplashes = [
|
||||
"bwhæhæhææhæhhææhæh",
|
||||
"Maximal opinions and Minimal evidence",
|
||||
"Who is the scrimbler bimbler?",
|
||||
"You wish you know who the hibble gibble gobbler was.",
|
||||
"You wish you knew who the hibble gibble gobbler was.",
|
||||
`The current day is ${FormatDate(Date.now())}, probably.`,
|
||||
"I have always thought of myself as an ambassador for the dance game community.",
|
||||
"When lava pours out near the sea surface, tremendous volcanic explosions sometimes occur.",
|
||||
|
||||
@@ -14,7 +14,10 @@ export function FormatDate(ms: number) {
|
||||
}
|
||||
|
||||
export function FormatDuration(ms: number) {
|
||||
return humaniseDuration(ms);
|
||||
return humaniseDuration(ms, {
|
||||
units: ["d", "h", "m"],
|
||||
maxDecimalPoints: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function FormatTimeSmall(ms: number) {
|
||||
|
||||
Reference in New Issue
Block a user