Dashboard page + Force Email Verification.

This commit is contained in:
zkldi
2021-11-22 10:36:38 +00:00
parent b0cef34cb1
commit 1f04d95a84
9 changed files with 425 additions and 50 deletions
+1 -1
View File
@@ -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>
);
}
+30 -1
View File
@@ -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>
);
}
+6
View File
@@ -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[];
}
+1 -1
View File
@@ -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.",
+4 -1
View File
@@ -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) {