mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-04 04:48:15 +03:00
User Page works
This commit is contained in:
@@ -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)} >={" "}
|
||||
{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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function BackgroundImage() {
|
||||
style={{
|
||||
backgroundImage: background
|
||||
? `url(${background})`
|
||||
: `url(${ToServerURL("/cdn/users/default/banner.png")})`,
|
||||
: `url(${ToServerURL("/cdn/splashes/default.png")})`,
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import GentleLink from "components/util/GentleLink";
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ChartDocument, SongDocument, Game } from "tachi-common";
|
||||
import { CreateChartLink } from "util/data";
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function TitleCell({
|
||||
}) {
|
||||
return (
|
||||
<td style={{ textAlign: "left", minWidth: "140px", maxWidth: "300px" }}>
|
||||
<Link className="song-title-link" to={CreateChartLink(chart, game)}>
|
||||
<GentleLink to={CreateChartLink(chart, game)}>
|
||||
{song.title}
|
||||
{!noArtist && (
|
||||
<>
|
||||
@@ -26,7 +26,7 @@ export default function TitleCell({
|
||||
<small>{song.artist}</small>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</GentleLink>
|
||||
{comment && (
|
||||
<>
|
||||
<br />
|
||||
|
||||
@@ -20,6 +20,7 @@ import GenericScoreContentDropdown from "./components/GenericScoreContentDropdow
|
||||
import PlayHistory from "./components/PlayHistory";
|
||||
import useApiQuery from "components/util/query/useApiQuery";
|
||||
import HasDevModeOn from "components/util/HasDevModeOn";
|
||||
import PBCompare from "./components/PBCompare";
|
||||
|
||||
export interface ScoreState {
|
||||
highlight: boolean;
|
||||
@@ -91,6 +92,8 @@ export default function GenericScoreDropdown<I extends IDStrings = IDStrings>({
|
||||
body = <DebugContent data={data} />;
|
||||
} else if (view === "moreInfo") {
|
||||
body = <DocComponent score={thisScore as any} scoreState={scoreState} pbData={data} />;
|
||||
} else if (view === "vsPB") {
|
||||
body = <PBCompare data={data} DocComponent={DocComponent} scoreState={scoreState} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -102,8 +105,8 @@ export default function GenericScoreDropdown<I extends IDStrings = IDStrings>({
|
||||
This Score
|
||||
</SelectButton>
|
||||
<SelectButton setValue={setView} value={view} id="vsPB">
|
||||
<Icon type="balance-scale-right" />
|
||||
Versus PB
|
||||
<Icon type="trophy" />
|
||||
Chart PB
|
||||
</SelectButton>
|
||||
<SelectButton setValue={setView} value={view} id="history">
|
||||
<Icon type="history" />
|
||||
|
||||
@@ -13,7 +13,7 @@ import JudgementTable from "./JudgementTable";
|
||||
import PBNote from "./PBNote";
|
||||
import ScoreEditButtons from "./ScoreEditButtons";
|
||||
|
||||
export function ScoreInfo({ score }: { score: ScoreDocument }) {
|
||||
export function ScoreInfo({ score }: { score: ScoreDocument | PBScoreDocument }) {
|
||||
return (
|
||||
<div className="col-12">
|
||||
<table className="table">
|
||||
@@ -33,7 +33,8 @@ export function ScoreInfo({ score }: { score: ScoreDocument }) {
|
||||
grade={score.scoreData.grade}
|
||||
/>
|
||||
<LampCell score={score} />
|
||||
<TimestampCell time={score.timeAchieved} service={score.service} />
|
||||
{/* @ts-expect-error yeah we know service doesnt necessarily exist */}
|
||||
<TimestampCell time={score.timeAchieved} service={score?.service} />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -47,6 +48,7 @@ export default function GenericScoreContentDropdown({
|
||||
renderScoreInfo = true,
|
||||
showSingleScoreNote = false,
|
||||
GraphComponent = null,
|
||||
forceScoreData = false,
|
||||
pbData,
|
||||
}: {
|
||||
score: ScoreDocument | PBScoreDocument;
|
||||
@@ -54,6 +56,7 @@ export default function GenericScoreContentDropdown({
|
||||
renderScoreInfo?: boolean;
|
||||
showSingleScoreNote?: boolean;
|
||||
pbData: UGPTChartPBComposition;
|
||||
forceScoreData?: boolean;
|
||||
GraphComponent?:
|
||||
| (({ score }: { score: ScoreDocument | PBScoreDocument }) => JSX.Element)
|
||||
| null;
|
||||
@@ -97,6 +100,7 @@ export default function GenericScoreContentDropdown({
|
||||
</>
|
||||
) : (
|
||||
<div className="col-12 align-self-end">
|
||||
{forceScoreData && !showSingleScoreNote && <ScoreInfo score={score} />}
|
||||
<CommentContainer
|
||||
comment={pbData.scores
|
||||
.map(e => e.comment)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
import { ScoreDocument, PBScoreDocument, IDStrings } from "tachi-common";
|
||||
import { UGPTChartPBComposition } from "types/api-returns";
|
||||
import { ScoreState } from "../GenericScoreDropdown";
|
||||
|
||||
export default function PBCompare<I extends IDStrings>({
|
||||
data,
|
||||
scoreState,
|
||||
DocComponent,
|
||||
}: {
|
||||
data: UGPTChartPBComposition<I>;
|
||||
scoreState: ScoreState;
|
||||
DocComponent: (props: {
|
||||
score: ScoreDocument<I> | PBScoreDocument<I>;
|
||||
scoreState: ScoreState;
|
||||
pbData: UGPTChartPBComposition;
|
||||
forceScoreData: boolean;
|
||||
}) => JSX.Element;
|
||||
}) {
|
||||
return <DocComponent score={data.pb} pbData={data} scoreState={scoreState} forceScoreData />;
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import ClassBadge from "components/game/ClassBadge";
|
||||
import { FormatGPTRating, UppercaseFirst } from "util/misc";
|
||||
import ProfileBadges from "./ProfileBadges";
|
||||
import { GameClassSets } from "tachi-common/js/game-classes";
|
||||
import UGPTRatingsTable from "./UGPTStatsOverview";
|
||||
import RankingData from "./UGPTRankingData";
|
||||
|
||||
export function UGPTHeaderBody({
|
||||
reqUser,
|
||||
@@ -73,44 +75,7 @@ export function UGPTHeaderBody({
|
||||
</MiniTable>
|
||||
</div>
|
||||
<div className="col-12 col-md-6 col-lg-3">
|
||||
<MiniTable className="table-sm text-center" headers={["Player Stats"]} colSpan={2}>
|
||||
<>
|
||||
{(Object.keys(
|
||||
gptConfig.classHumanisedFormat
|
||||
) as GameClassSets[IDStrings][]).map(k => (
|
||||
<tr key={k}>
|
||||
<td>{UppercaseFirst(k)}</td>
|
||||
<td>
|
||||
{stats.gameStats.classes[k] ? (
|
||||
<ClassBadge
|
||||
showSetOnHover={false}
|
||||
key={`${k}:${stats.gameStats.classes[k]}`}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
classSet={k}
|
||||
classValue={stats.gameStats.classes[k]!}
|
||||
/>
|
||||
) : (
|
||||
"No Data"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{Object.entries(stats.gameStats.ratings).map(([k, v]) => (
|
||||
<tr key={k}>
|
||||
<td>{UppercaseFirst(k)}</td>
|
||||
<td>
|
||||
{FormatGPTRating(
|
||||
game,
|
||||
playtype,
|
||||
k as ScoreCalculatedDataLookup[IDStrings],
|
||||
v
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
</MiniTable>
|
||||
<UGPTRatingsTable ugs={stats.gameStats} />
|
||||
</div>
|
||||
<div className="col-12 col-lg-3">
|
||||
<RankingData ranking={stats.rankingData.ranking} outOf={stats.rankingData.outOf} />
|
||||
@@ -119,20 +84,6 @@ export function UGPTHeaderBody({
|
||||
);
|
||||
}
|
||||
|
||||
function RankingData({ ranking, outOf }: { ranking: number; outOf: number }) {
|
||||
return (
|
||||
<div className="row text-center">
|
||||
<div className="col-12">
|
||||
<h4>Ranking</h4>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<strong className="display-4">#{ranking}</strong>
|
||||
<span className="text-muted">/{outOf}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UGPTBottomNav({ baseUrl }: { baseUrl: string }) {
|
||||
return (
|
||||
<div className="row align-items-center mb-0">
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
|
||||
export default function RankingData({ ranking, outOf }: { ranking: number; outOf: number }) {
|
||||
return (
|
||||
<div className="row text-center">
|
||||
<div className="col-12">
|
||||
<h4>Ranking</h4>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<strong className="display-4">#{ranking}</strong>
|
||||
<span className="text-muted">/{outOf}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import Card from "components/layout/page/Card";
|
||||
import CardHeader from "components/layout/page/CardHeader";
|
||||
import CardNavButton from "components/layout/page/CardNavButton";
|
||||
import AsyncLoader from "components/util/AsyncLoader";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import { UserGameStatsContext } from "context/UserGameStatsContext";
|
||||
import { nanoid } from "nanoid";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
PublicUserDocument,
|
||||
Game,
|
||||
GetGamePTConfig,
|
||||
SongDocument,
|
||||
ChartDocument,
|
||||
FormatChart,
|
||||
FolderDocument,
|
||||
} from "tachi-common";
|
||||
import { UGPTPreferenceStatsReturn } from "types/api-returns";
|
||||
import { GamePT } from "types/react";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { APIFetchV1 } from "util/api";
|
||||
|
||||
export default 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="edit"
|
||||
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)} >={" "}
|
||||
{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 <></>;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import ClassBadge from "components/game/ClassBadge";
|
||||
import MiniTable from "components/tables/components/MiniTable";
|
||||
import React from "react";
|
||||
import { GetGamePTConfig, IDStrings, ScoreCalculatedDataLookup, UserGameStats } from "tachi-common";
|
||||
import { GameClassSets } from "tachi-common/js/game-classes";
|
||||
import { UppercaseFirst, FormatGPTRating } from "util/misc";
|
||||
import { MillisToSince } from "util/time";
|
||||
|
||||
export default function UGPTRatingsTable({ ugs }: { ugs: UserGameStats }) {
|
||||
const gptConfig = GetGamePTConfig(ugs.game, ugs.playtype);
|
||||
|
||||
return (
|
||||
<MiniTable className="table-sm text-center" headers={["Player Stats"]} colSpan={2}>
|
||||
<>
|
||||
{(Object.keys(gptConfig.classHumanisedFormat) as GameClassSets[IDStrings][]).map(
|
||||
k => (
|
||||
<tr key={k}>
|
||||
<td>{UppercaseFirst(k)}</td>
|
||||
<td>
|
||||
{ugs.classes[k] ? (
|
||||
<ClassBadge
|
||||
showSetOnHover={false}
|
||||
key={`${k}:${ugs.classes[k]}`}
|
||||
game={ugs.game}
|
||||
playtype={ugs.playtype}
|
||||
classSet={k}
|
||||
classValue={ugs.classes[k]!}
|
||||
/>
|
||||
) : (
|
||||
"No Data"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
{Object.entries(ugs.ratings).map(([k, v]) => (
|
||||
<tr key={k}>
|
||||
<td>{UppercaseFirst(k)}</td>
|
||||
<td>
|
||||
{FormatGPTRating(
|
||||
ugs.game,
|
||||
ugs.playtype,
|
||||
k as ScoreCalculatedDataLookup[IDStrings],
|
||||
v
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
</MiniTable>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import Navbar from "components/nav/Navbar";
|
||||
import NavItem from "components/nav/NavItem";
|
||||
import ProfileBadges from "./ProfileBadges";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import Muted from "components/util/Muted";
|
||||
|
||||
export function UserHeaderBody({ reqUser }: { reqUser: PublicUserDocument }) {
|
||||
return (
|
||||
@@ -21,6 +22,10 @@ export function UserHeaderBody({ reqUser }: { reqUser: PublicUserDocument }) {
|
||||
<Divider className="mt-4 mb-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-lg-6 d-flex justify-content-center">{reqUser.about}</div>
|
||||
<div className="col-12 col-lg-3 d-flex justify-content-center">
|
||||
<Muted>UserID: {reqUser.id}</Muted>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +43,7 @@ export function UserBottomNav({
|
||||
|
||||
const navItems = [
|
||||
<NavItem key="about" to={`${baseUrl}/`}>
|
||||
About
|
||||
Overview
|
||||
</NavItem>,
|
||||
<NavItem key="games" to={`${baseUrl}/games`}>
|
||||
Games
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { JustChildren } from "types/react";
|
||||
|
||||
export default function GentleLink({
|
||||
to,
|
||||
children,
|
||||
}: {
|
||||
to: string;
|
||||
className?: string;
|
||||
} & JustChildren) {
|
||||
return (
|
||||
<Link to={to} className="gentle-link">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from "react";
|
||||
import { JustChildren } from "types/react";
|
||||
|
||||
export default function Muted({ children }: JustChildren) {
|
||||
return <small className="text-muted">{children}</small>;
|
||||
}
|
||||
@@ -80,3 +80,7 @@ export interface SpecificSessionReturns<I extends IDStrings = IDStrings> {
|
||||
charts: ChartDocument<I>[];
|
||||
user: PublicUserDocument;
|
||||
}
|
||||
|
||||
export type UGSWithRankingData<I extends IDStrings = IDStrings> = UserGameStats<I> & {
|
||||
__rankingData: { outOf: number; ranking: number };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user