User Page works

This commit is contained in:
zkldi
2021-08-27 08:38:57 +01:00
parent 4a8f2e3969
commit 8d0fdab607
21 changed files with 1153 additions and 408 deletions
@@ -0,0 +1,89 @@
import React, { useContext, useState, useMemo, useEffect } from "react";
import { FormatGame, PublicUserDocument, UserGameStats } from "tachi-common";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Card from "components/layout/page/Card";
import ReactMarkdown from "react-markdown";
import { UserContext } from "context/UserContext";
import { Button } from "react-bootstrap";
import { APIFetchV1 } from "util/api";
import Divider from "components/util/Divider";
import Muted from "components/util/Muted";
import ExternalLink from "components/util/ExternalLink";
import AsyncLoader from "components/util/AsyncLoader";
import UGPTRatingsTable from "components/user/UGPTStatsOverview";
import LinkButton from "components/util/LinkButton";
import { UGSWithRankingData } from "types/api-returns";
import RankingData from "components/user/UGPTRankingData";
interface Props {
reqUser: PublicUserDocument;
}
export default function UserGamesPage({ reqUser }: Props) {
useSetSubheader(["Users", reqUser.username], [reqUser], `${reqUser.username}'s Profile`);
return (
<div className="row">
<AsyncLoader
promiseFn={async () => {
const res = await APIFetchV1<UGSWithRankingData[]>(
`/users/${reqUser.id}/game-stats`
);
if (!res.success) {
throw new Error(res.description);
}
return res.body;
}}
>
{ugs =>
ugs.map((e, i) => {
// 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) {
lgSize = (12 / (ugs.length % 3)).toString();
}
if (ugs.length - i <= 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>
);
})
}
</AsyncLoader>
</div>
);
}
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 ranking={ugs.__rankingData.ranking} outOf={ugs.__rankingData.outOf} />
</Card>
);
}
@@ -1,16 +1,109 @@
import Loading from "components/util/Loading";
import React from "react";
import React, { useContext, useState, useMemo, useEffect } from "react";
import { PublicUserDocument } from "tachi-common";
import { ToAPIURL } from "util/api";
import Divider from "components/util/Divider";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Card from "components/layout/page/Card";
import ReactMarkdown from "react-markdown";
import { UserContext } from "context/UserContext";
import { Button } from "react-bootstrap";
import { APIFetchV1 } from "util/api";
import Divider from "components/util/Divider";
import Muted from "components/util/Muted";
import ExternalLink from "components/util/ExternalLink";
export default function UserPage({ reqUser }: { reqUser: PublicUserDocument }) {
interface Props {
reqUser: PublicUserDocument;
}
export default function UserPage({ reqUser }: Props) {
useSetSubheader(["Users", reqUser.username], [reqUser], `${reqUser.username}'s Profile`);
if (!reqUser) {
return <Loading />;
return <AboutMeCard reqUser={reqUser} />;
}
function AboutMeCard({ reqUser }: Props) {
const { user } = useContext(UserContext);
const [editMode, setEditMode] = useState(false);
const [content, setContent] = useState(reqUser.about);
async function SubmitNewAboutMe(text: string) {
const res = await APIFetchV1(
`/users/${user!.id}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ about: text }),
},
true,
true
);
setEditMode(!res.success);
}
return <></>;
useEffect(() => {
if (
editMode &&
(content === "<script>alert(1)</script>" || content === "<script>alert(1);</script>")
) {
alert(`1\n\n\n...Just kidding, this is an easter egg :)`);
}
}, [content]);
const footer = useMemo(() => {
if (user?.id !== reqUser.id) {
return <></>;
}
if (!editMode) {
return (
<div className="d-flex justify-content-end">
<Button onClick={() => setEditMode(true)} variant="info">
Edit
</Button>
</div>
);
}
return (
<div className="d-flex">
<Button className="mr-auto" onClick={() => setEditMode(false)} variant="secondary">
Cancel
</Button>
<Button
className="ml-auto"
onClick={() => SubmitNewAboutMe(content)}
variant="success"
>
Submit
</Button>
</div>
);
}, [editMode, reqUser]);
return (
<Card header="About Me" footer={footer}>
{editMode && (
<>
<textarea
style={{ height: 200 }}
className="w-100"
value={content}
onChange={e => setContent(e.target.value)}
/>
<Muted>
You can use{" "}
<ExternalLink href="https://www.markdownguide.org/basic-syntax/">
markdown
</ExternalLink>{" "}
here!
</Muted>
<Divider className="mt-4 mb-4" />
</>
)}
<ReactMarkdown>{content}</ReactMarkdown>
</Card>
);
}
@@ -2,6 +2,7 @@ import ClassBadge from "components/game/ClassBadge";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Card from "components/layout/page/Card";
import MiniTable from "components/tables/components/MiniTable";
import GentleLink from "components/util/GentleLink";
import LoadingWrapper from "components/util/LoadingWrapper";
import React from "react";
import { useQuery } from "react-query";
@@ -115,7 +116,11 @@ function LeaderboardsPageContent({
<small className="text-muted">/{stats.thisUsersRanking.outOf}</small>
)}
</td>
<td>{userMap.get(s.userID)?.username}</td>
<td>
<GentleLink to={`/dashboard/users/${userMap.get(s.userID)?.username}`}>
{userMap.get(s.userID)?.username}
</GentleLink>
</td>
<td>{(s.ratings[gptConfig.defaultProfileRatingAlg] ?? 0).toFixed(2)}</td>
{/* temp */}
<td>
@@ -43,6 +43,7 @@ import IIDXScoreTable from "components/tables/scores/IIDXScoreTable";
import { NumericSOV } from "util/sorts";
import { ScoreDataset } from "types/tables";
import ScoreTable from "components/tables/scores/ScoreTable";
import StatShowcase from "components/user/UGPTStatShowcase";
export default function OverviewPage({
reqUser,
@@ -480,339 +481,3 @@ function RankingTimeline({ data }: { data: UGPTHistory }) {
/>
);
}
function StatShowcase({ reqUser, game, playtype }: { reqUser: PublicUserDocument } & GamePT) {
const { ugs } = useContext(UserGameStatsContext);
const { user } = useContext(UserContext);
const [projectingStats, setProjectingStats] = useState(false);
const hasUserPlayedGame =
ugs && !!ugs.filter(e => e.game === game && e.playtype === playtype).length;
const userIsReqUser = user && user.id === reqUser.id;
const shouldFetchThisUserData = hasUserPlayedGame && !userIsReqUser;
return (
<Card
className="card-dark"
header={
<CardHeader
rightContent={
userIsReqUser ? (
<CardNavButton
type="cog"
to={`/dashboard/users/${
user!.username
}/games/${game}/${playtype}/settings`}
hoverText="Modify your statistics showcase."
/>
) : null
}
>
<h3>
{projectingStats
? `${user!.username}'s Stat Showcase (Projected onto ${
reqUser.username
})`
: `${reqUser.username}'s Stat Showcase`}
</h3>
</CardHeader>
}
footer={
<div className="d-flex w-100 justify-content-center">
<div className="btn-group">
{hasUserPlayedGame &&
!userIsReqUser &&
(projectingStats ? (
<OverlayTrigger
placement="top"
overlay={
<Tooltip id="quick-panel-tooltip">
Return to {reqUser.username}'s selected stats.
</Tooltip>
}
>
<div
className="btn btn-success"
onClick={() => setProjectingStats(false)}
>
<i className="fas fa-sync" style={{ paddingRight: 0 }} />
</div>
</OverlayTrigger>
) : (
<OverlayTrigger
placement="top"
overlay={
<Tooltip id={nanoid()}>
Change the displayed stats to the same ones you use!
</Tooltip>
}
>
<div
className="btn btn-outline-secondary"
onClick={() => setProjectingStats(true)}
>
<i className="fas fa-sync" style={{ paddingRight: 0 }} />
</div>
</OverlayTrigger>
))}
<OverlayTrigger
placement="top"
overlay={
<Tooltip id="quick-panel-tooltip">
Evaluate a custom statistic.
</Tooltip>
}
>
<div className="btn btn-outline-secondary">
<i className="fas fa-file-signature" style={{ paddingRight: 0 }} />
</div>
</OverlayTrigger>
</div>
</div>
}
>
<AsyncLoader
promiseFn={async () => {
const res = await APIFetchV1<UGPTPreferenceStatsReturn[]>(
`/users/${reqUser.id}/games/${game}/${playtype}/showcase${
projectingStats ? `?projectUser=${user!.id}` : ""
}`
);
if (!res.success) {
throw new Error(res.description);
}
if (shouldFetchThisUserData) {
const res2 = await APIFetchV1<UGPTPreferenceStatsReturn[]>(
`/users/${user!.id}/games/${game}/${playtype}/showcase${
!projectingStats ? `?projectUser=${reqUser.id}` : ""
}`
);
if (!res2.success) {
throw new Error(res2.description);
}
return { reqUserData: res.body, thisUserData: res2.body };
}
return { reqUserData: res.body };
}}
>
{data => (
<div className="container">
{data.reqUserData.length === 0 ? (
<div className="row">
<div className="col-12 text-center">
{userIsReqUser ? "You have" : "This user has"} no stats
configured.
</div>
{userIsReqUser && (
<div className="col-12 mt-2 text-center">
Why not{" "}
<Link
to={`/dashboard/users/${
user!.username
}/games/${game}/${playtype}/settings`}
>
Set Some?
</Link>
</div>
)}
</div>
) : (
<div className="row justify-content-center">
{data.reqUserData.map((e, i) => (
<div
key={nanoid()}
className="col-12 col-md-4 d-flex align-items-stretch mt-8"
>
<StatDisplay
statData={e}
compareData={
data.thisUserData ? data.thisUserData[i] : undefined
}
game={game}
playtype={playtype}
/>
</div>
))}
</div>
)}
</div>
)}
</AsyncLoader>
</Card>
);
}
function StatDelta({
v1,
v2,
mode,
property,
game,
playtype,
}: {
v1: number;
v2?: number;
mode: "folder" | "chart";
property: UGPTPreferenceStatsReturn["stat"]["property"];
game: Game;
playtype: Playtype;
}) {
if (!v2) {
return null;
}
let d: string | number = v2 - v1;
const formattedV2 = FormatValue(game, playtype, mode, property, v2);
if (property === "percent") {
d = `${d.toFixed(2)}%`;
}
let colour;
if (v2 === v1) {
colour = "warning";
} else if (v2 > v1) {
colour = "success";
} else {
colour = "danger";
}
return (
<div className={`mt-2 text-${colour}`}>
<span>
You: {formattedV2} ({v2 > v1 ? `+${d}` : d})
</span>
</div>
);
}
function FormatValue(
game: Game,
playtype: Playtype,
mode: "folder" | "chart",
prop: "grade" | "lamp" | "score" | "percent" | "playcount",
value: number
) {
if (mode === "folder") {
return value;
}
const gptConfig = GetGamePTConfig(game, playtype);
if (prop === "percent") {
return `${value.toFixed(2)}%`;
} else if (prop === "grade") {
return gptConfig.grades[value];
} else if (prop === "lamp") {
return gptConfig.lamps[value];
}
return value;
}
function FormatPropertyGTE(
game: Game,
playtype: Playtype,
prop: "grade" | "lamp" | "score" | "percent" | "playcount",
gte: number
) {
const gptConfig = GetGamePTConfig(game, playtype);
if (prop === "grade") {
return gptConfig.grades[gte];
} else if (prop === "lamp") {
return gptConfig.lamps[gte];
} else if (prop === "score" || prop === "playcount") {
return gte;
}
// else if (prop === "percent") {
return gte.toFixed(2);
// }
}
function StatDisplay({
statData,
compareData,
game,
playtype,
}: { statData: UGPTPreferenceStatsReturn; compareData?: UGPTPreferenceStatsReturn } & GamePT) {
const { stat, value, related } = statData;
if (stat.mode === "chart") {
const { song, chart } = related as { song: SongDocument; chart: ChartDocument };
return (
<Card
className="text-center stat-overview-card w-100"
header={<h5 className="text-muted mb-0">Chart</h5>}
>
<>
<h4>{FormatChart(game, song, chart)}</h4>
<h4>
{stat.property[0].toUpperCase() + stat.property.substring(1)}:{" "}
{value.value
? stat.property === "percent"
? `${value.value.toFixed(2)}%`
: value.value
: "No Data."}
</h4>
<StatDelta
v1={statData.value.value}
v2={compareData?.value.value}
mode={stat.mode}
property={stat.property}
game={game}
playtype={playtype}
/>
</>
</Card>
);
} else if (stat.mode === "folder") {
const { folders } = related as { folders: FolderDocument[] };
let headerStr;
if (folders.length === 1) {
headerStr = folders[0].title;
} else {
headerStr = folders.map(e => e.title).join(" and ");
}
return (
<Card
className="text-center stat-overview-card w-100"
header={<h5 className="text-muted mb-0">Folder</h5>}
>
<>
<h4>{headerStr}</h4>
<h5>
{stat.property[0].toUpperCase() + stat.property.substring(1)} &gt;={" "}
{FormatPropertyGTE(game, playtype, stat.property, stat.gte)}
</h5>
<h4>
{value.value}
{/* @ts-expect-error temp */}
<small className="text-muted">/{value.outOf}</small>
</h4>
<StatDelta
v1={statData.value.value}
v2={compareData?.value.value}
mode={stat.mode}
property={stat.property}
game={game}
playtype={playtype}
/>
</>
</Card>
);
}
return <></>;
}
@@ -16,7 +16,6 @@ import {
} from "tachi-common";
import { GamePT } from "types/react";
import { APIFetchV1 } from "util/api";
import IIDXScoreTable from "components/tables/scores/IIDXScoreTable";
import LoadingWrapper from "components/util/LoadingWrapper";
import Icon from "components/util/Icon";
import SelectButton from "components/util/SelectButton";
+4
View File
@@ -2,6 +2,7 @@ import PlaytypeSelect from "app/pages/dashboard/games/_game/PlaytypeSelect";
import LeaderboardsPage from "app/pages/dashboard/users/games/_game/_playtype/LeaderboardsPage";
import OverviewPage from "app/pages/dashboard/users/games/_game/_playtype/OverviewPage";
import SessionsPage from "app/pages/dashboard/users/games/_game/_playtype/SessionsPage";
import UserGamesPage from "app/pages/dashboard/users/UserGamesPage";
import { ErrorPage } from "app/pages/ErrorPage";
import RequireAuthAsUserParam from "components/auth/RequireAuthAsUserParam";
import { UGPTBottomNav, UGPTHeaderBody } from "components/user/UGPTHeader";
@@ -92,6 +93,9 @@ function UserProfileRoutes({ reqUser }: { reqUser: PublicUserDocument }) {
<Route exact path="/dashboard/users/:userID">
<UserPage reqUser={reqUser} />
</Route>
<Route exact path="/dashboard/users/:userID/games">
<UserGamesPage reqUser={reqUser} />
</Route>
<Route exact path="/dashboard/users/:userID/settings">
<RequireAuthAsUserParam>Settings Page</RequireAuthAsUserParam>
</Route>