mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-11 00:38:28 +03:00
Folder Update
This commit is contained in:
+2
-1
@@ -30,6 +30,7 @@
|
||||
"@material-ui/lab": "4.0.0-alpha.53",
|
||||
"@material-ui/pickers": "3.2.10",
|
||||
"@material-ui/styles": "4.9.14",
|
||||
"@nivo/bar": "^0.73.1",
|
||||
"@nivo/calendar": "^0.73.0",
|
||||
"@nivo/core": "^0.73.0",
|
||||
"@nivo/line": "^0.73.0",
|
||||
@@ -74,7 +75,7 @@
|
||||
"resolve": "1.12.0",
|
||||
"resolve-url-loader": "3.1.0",
|
||||
"socicon": "3.0.5",
|
||||
"tachi-common": "0.2.4"
|
||||
"tachi-common": "0.2.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/humanize-duration": "3.25.1",
|
||||
|
||||
Generated
+104
-877
File diff suppressed because it is too large
Load Diff
@@ -3,46 +3,83 @@ import SiteWordmark from "components/util/SiteWordmark";
|
||||
import { Form, Col, Button } from "react-bootstrap";
|
||||
import React, { useState } from "react";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import MainPageTitleContainer from "components/util/MainPageTitleContainer";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const history = useHistory();
|
||||
const [hasResetPassword, setHasResetPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<CenterPage>
|
||||
<Col lg="6">
|
||||
<SiteWordmark />
|
||||
<div className="text-center mb-10 mb-lg-20">
|
||||
<h3 className="font-size-h1">Forgot Password</h3>
|
||||
<span className="font-weight-bold text-dark-50">
|
||||
Hey, it happens to all of us.
|
||||
</span>
|
||||
</div>
|
||||
<Form>
|
||||
<Form.Group>
|
||||
<Form.Label>Email Address</Form.Label>
|
||||
<Form.Control
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="justify-content-center d-flex pt-4">
|
||||
<span
|
||||
onClick={() => history.goBack()}
|
||||
tabIndex={4}
|
||||
className="mr-auto btn btn-outline-danger"
|
||||
<MainPageTitleContainer
|
||||
title="Forgot Password"
|
||||
desc="Hey, it happens to all of us :P"
|
||||
/>
|
||||
{hasResetPassword ? (
|
||||
<div>
|
||||
All good! An email has been sent to {email} IF an account exists with that
|
||||
email.
|
||||
<br />
|
||||
<Link to="/">Go home.</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Form
|
||||
onSubmit={async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const res = await APIFetchV1(
|
||||
"/auth/forgot-password",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
},
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
if (res.success) {
|
||||
setTimeout(() => setHasResetPassword(true), 300);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</span>
|
||||
<Button tabIndex={3} type="submit" className="ml-auto">
|
||||
Send Reset Link
|
||||
</Button>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
<Link to="/screwed">
|
||||
I signed up with a fake email, how can I recover my account?
|
||||
</Link>
|
||||
<Form.Group>
|
||||
<Form.Label>Email Address</Form.Label>
|
||||
<Form.Control
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="justify-content-center d-flex pt-4">
|
||||
<span
|
||||
onClick={() => history.goBack()}
|
||||
tabIndex={4}
|
||||
className="mr-auto btn btn-outline-danger"
|
||||
>
|
||||
Back
|
||||
</span>
|
||||
<Button
|
||||
tabIndex={3}
|
||||
type="submit"
|
||||
className="ml-auto"
|
||||
disabled={email === ""}
|
||||
>
|
||||
Send Reset Link
|
||||
</Button>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
<Link to="/screwed">
|
||||
I signed up with a fake email, how can I recover my account?
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
</CenterPage>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Footer } from "components/layout/footer/Footer";
|
||||
import { Header } from "components/layout/header/Header";
|
||||
import { HeaderMobile } from "components/layout/header/HeaderMobile";
|
||||
import Divider from "components/util/Divider";
|
||||
import LinkButton from "components/util/LinkButton";
|
||||
import { TachiConfig } from "lib/config";
|
||||
import React from "react";
|
||||
import { ToServerURL } from "util/api";
|
||||
import { toAbsoluteUrl } from "_metronic/_helpers";
|
||||
|
||||
// This page is currently unused,
|
||||
// It's alright, but hey, we could do more.
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<>
|
||||
<HeaderMobile />
|
||||
<Header />
|
||||
<div className="landing-page" id="kt_content">
|
||||
<section id="hero" className="hero">
|
||||
<div
|
||||
className="hero-bg-image"
|
||||
style={{
|
||||
backgroundImage: `url(${ToServerURL("/cdn/splashes/default.png")})`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="hero-bg-fader"
|
||||
style={{
|
||||
backgroundImage: `url(${ToServerURL("/cdn/misc/overlay.png")})`,
|
||||
}}
|
||||
/>
|
||||
<div className="hero-content-wrapper">
|
||||
<div className="row">
|
||||
<div className="col-12 col-lg-8 offset-lg-2 d-flex align-items-center">
|
||||
<div className="row justify-content-center text-center">
|
||||
<div className="col-12 mb-8">
|
||||
<div className="d-none d-lg-block">
|
||||
<img
|
||||
src={toAbsoluteUrl(
|
||||
"/media/logos/logo-wordmark.png"
|
||||
)}
|
||||
alt={TachiConfig.name}
|
||||
width="30%"
|
||||
/>
|
||||
</div>
|
||||
<div className="d-block d-lg-none">
|
||||
<img
|
||||
src={toAbsoluteUrl(
|
||||
"/media/logos/logo-wordmark.png"
|
||||
)}
|
||||
alt={TachiConfig.name}
|
||||
width="80%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<h3 className="display-3">
|
||||
A <b>Supercharged</b> Score Tracker
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Divider className="mt-8 mb-16" />
|
||||
<section id="features" className="features">
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<FeatureContainer
|
||||
tagline="No More Spreadsheets."
|
||||
description={`${TachiConfig.name} analyses your scores for you, showing all the statistics you'll ever want to see.`}
|
||||
>
|
||||
<FeatureImage
|
||||
alt="Picture of scores."
|
||||
src={ToServerURL("/cdn/hero/scores.png")}
|
||||
/>
|
||||
</FeatureContainer>
|
||||
<FeatureContainer
|
||||
leftAlign={false}
|
||||
tagline="Features You'll Actually Use."
|
||||
description={`${TachiConfig.name} implements the features rhythm gamers already talk about. Break your scores down into sessions, Track goals in real time, break down your folder averages - it's all there!`}
|
||||
>
|
||||
foo
|
||||
</FeatureContainer>
|
||||
<FeatureContainer
|
||||
tagline="All Your Scores."
|
||||
description={`${TachiConfig.name} supports a bunch of your favourite games, and integrates with many existing services to make sure no score is lost to the void.`}
|
||||
>
|
||||
foo
|
||||
</FeatureContainer>
|
||||
<div
|
||||
className="col-12 text-center"
|
||||
style={{ paddingTop: 100, height: 250 }}
|
||||
>
|
||||
But hey, this all sounds a bit formal. Let's be honest, if you're a
|
||||
rhythm gamer, you probably don't need to be sold on this like it's a
|
||||
startup :P.
|
||||
<br />
|
||||
<LinkButton to="/register" className="mt-4 btn-outline-primary">
|
||||
Register?
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureContainer({
|
||||
tagline,
|
||||
description,
|
||||
children,
|
||||
leftAlign = true,
|
||||
}: {
|
||||
tagline: string;
|
||||
description: string;
|
||||
children: React.ReactChild;
|
||||
leftAlign?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="col-12 mb-16">
|
||||
<div className="row">
|
||||
{leftAlign && (
|
||||
<div className="col-12 col-lg-6 align-self-center">
|
||||
<h1 className="display-4">{tagline}</h1>
|
||||
<h5>{description}</h5>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-12 d-none d-lg-block col-lg-6 text-center">{children}</div>
|
||||
{!leftAlign && (
|
||||
<div className="col-12 col-lg-6 text-right">
|
||||
<h1 className="display-4">{tagline}</h1>
|
||||
<h5>{description}</h5>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureImage({ src, alt }: { src: string; alt: string }) {
|
||||
return <img src={src} alt={alt} />;
|
||||
}
|
||||
@@ -1,5 +1,77 @@
|
||||
import React from "react";
|
||||
import CenterPage from "components/util/CenterPage";
|
||||
import MainPageTitleContainer from "components/util/MainPageTitleContainer";
|
||||
import React, { useState } from "react";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { ShortDelayify } from "util/misc";
|
||||
import { ErrorPage } from "./ErrorPage";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return <div></div>;
|
||||
const code = new URLSearchParams(window.location.search).get("code");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPass, setConfirmPass] = useState("");
|
||||
|
||||
if (!code) {
|
||||
return <ErrorPage statusCode={400} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterPage>
|
||||
<MainPageTitleContainer
|
||||
title="Reset Password"
|
||||
desc="Pick something you'll remember this time :)"
|
||||
/>
|
||||
<Form
|
||||
onSubmit={async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const res = await APIFetchV1(
|
||||
"/auth/reset-password",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ code, password }),
|
||||
},
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
if (res.success) {
|
||||
ShortDelayify(() => (window.location.href = "/"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Group>
|
||||
<Form.Label>New Password</Form.Label>
|
||||
<Form.Control
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
isValid={password.length >= 8}
|
||||
type="password"
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Label>Confirm</Form.Label>
|
||||
<Form.Control
|
||||
value={confirmPass}
|
||||
onChange={e => setConfirmPass(e.target.value)}
|
||||
isValid={password === confirmPass}
|
||||
type="password"
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="justify-content-center d-flex pt-4">
|
||||
<Button
|
||||
tabIndex={3}
|
||||
type="submit"
|
||||
className="ml-auto"
|
||||
disabled={!(password === confirmPass && password.length >= 8)}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</CenterPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,6 +107,10 @@ export default function CreditsPage() {
|
||||
{a("APIs, Massive Dev Support", "Have you tried Elixir?")}:{" "}
|
||||
<strong>haste</strong>
|
||||
</li>
|
||||
<li>
|
||||
{a("Tachi-Bot Work", "oooOoooo you want to use redux oooo")}:{" "}
|
||||
<strong>pfych</strong>
|
||||
</li>
|
||||
<li>
|
||||
{a("Chunitachi", "CHUNITHM Black Magic")}: <strong>tomatosoup</strong>
|
||||
</li>
|
||||
@@ -146,8 +150,8 @@ export default function CreditsPage() {
|
||||
<ExternalLink href="https://pnpm.io">PNPM</ExternalLink>
|
||||
<br />
|
||||
<small>
|
||||
PNPM is a monumental improvement over NPM and Yarn. I cannot recommend
|
||||
it highly enough.
|
||||
PNPM is a monumental improvement over NPM. I cannot recommend it highly
|
||||
enough.
|
||||
</small>
|
||||
</li>
|
||||
<li>
|
||||
@@ -180,6 +184,11 @@ export default function CreditsPage() {
|
||||
one guy.
|
||||
</small>
|
||||
</li>
|
||||
<li>
|
||||
<ExternalLink href="https://nivo.rocks">Nivo</ExternalLink>
|
||||
<br />
|
||||
<small>Nivo is the best graphing library ever made. That's it.</small>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<span className="text-muted" style={{ fontSize: "0.2rem" }}>
|
||||
|
||||
@@ -15,6 +15,7 @@ import LinkButton from "components/util/LinkButton";
|
||||
import { UGSWithRankingData } from "types/api-returns";
|
||||
import RankingData, { LazyRankingData } from "components/user/UGPTRankingData";
|
||||
import ReferToUser from "components/util/ReferToUser";
|
||||
import { GetSortedGPTs } from "util/site";
|
||||
|
||||
interface Props {
|
||||
reqUser: PublicUserDocument;
|
||||
@@ -44,29 +45,7 @@ export default function UserGamesPage({ reqUser }: Props) {
|
||||
>
|
||||
{ugs =>
|
||||
ugs.length ? (
|
||||
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>
|
||||
);
|
||||
})
|
||||
<GamesInfo ugs={ugs} reqUser={reqUser} />
|
||||
) : (
|
||||
<div className="col-12 text-center">
|
||||
<Muted>
|
||||
@@ -80,6 +59,50 @@ export default function UserGamesPage({ reqUser }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function GamesInfo({ ugs, reqUser }: { ugs: UserGameStats[]; reqUser: PublicUserDocument }) {
|
||||
const gpts = GetSortedGPTs();
|
||||
|
||||
const ugsMap = new Map();
|
||||
|
||||
for (const u of ugs) {
|
||||
ugsMap.set(`${u.game}:${u.playtype}`, u);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{gpts.map(({ game, playtype }, i) => {
|
||||
const e = ugsMap.get(`${game}:${playtype}`);
|
||||
|
||||
if (!e) {
|
||||
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) {
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GameStatContainer({ ugs, reqUser }: { ugs: UGSWithRankingData } & Props) {
|
||||
return (
|
||||
<Card
|
||||
|
||||
@@ -27,7 +27,6 @@ function AboutMeCard({ reqUser }: Props) {
|
||||
const [content, setContent] = useState(reqUser.about);
|
||||
|
||||
async function SubmitNewAboutMe() {
|
||||
console.log(content);
|
||||
const res = await APIFetchV1(
|
||||
`/users/${user!.id}`,
|
||||
{
|
||||
@@ -91,8 +90,6 @@ function AboutMeCard({ reqUser }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(content);
|
||||
|
||||
return (
|
||||
<Card header="About Me" footer={footer}>
|
||||
{editMode && (
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import useSetSubheader from "components/layout/header/useSetSubheader";
|
||||
import React from "react";
|
||||
import { FormatGame, GetGameConfig, PublicUserDocument } from "tachi-common";
|
||||
import { GamePT } from "types/react";
|
||||
|
||||
type Props = { reqUser: PublicUserDocument } & GamePT;
|
||||
|
||||
export default function FoldersPage({ reqUser, game, playtype }: Props) {
|
||||
const gameConfig = GetGameConfig(game);
|
||||
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Achievables"],
|
||||
[reqUser, game, playtype],
|
||||
`${reqUser.username}'s ${FormatGame(game, playtype)} Goals & Milestones`
|
||||
);
|
||||
|
||||
return <div></div>;
|
||||
}
|
||||
@@ -21,12 +21,12 @@ import { GamePT, SetState } from "types/react";
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import LoadingWrapper from "components/util/LoadingWrapper";
|
||||
import Icon from "components/util/Icon";
|
||||
import SelectButton from "components/util/SelectButton";
|
||||
import SelectLinkButton from "components/util/SelectLinkButton";
|
||||
import PBTable from "components/tables/pbs/PBTable";
|
||||
import ScoreTable from "components/tables/scores/ScoreTable";
|
||||
import useScoreRatingAlg from "components/util/useScoreRatingAlg";
|
||||
import { Alert } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import useUGPTBase from "components/util/useUGPTBase";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
|
||||
export default function ScoresPage({
|
||||
reqUser,
|
||||
@@ -35,8 +35,6 @@ export default function ScoresPage({
|
||||
}: {
|
||||
reqUser: PublicUserDocument;
|
||||
} & GamePT) {
|
||||
const [scoreSet, setScoreSet] = useState<"recent" | "best" | "all" | "playcount">("best");
|
||||
|
||||
const gameConfig = GetGameConfig(game);
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
@@ -50,59 +48,66 @@ export default function ScoresPage({
|
||||
`${reqUser.username}'s ${FormatGame(game, playtype)} Scores`
|
||||
);
|
||||
|
||||
const base = useUGPTBase({ reqUser, game, playtype });
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-12 text-center">
|
||||
<div className="btn-group mb-4">
|
||||
<SelectButton id="best" setValue={setScoreSet} value={scoreSet}>
|
||||
<SelectLinkButton to={`${base}/scores`}>
|
||||
<Icon type="trophy" />
|
||||
Best PBs
|
||||
</SelectButton>
|
||||
<SelectButton id="recent" setValue={setScoreSet} value={scoreSet}>
|
||||
</SelectLinkButton>
|
||||
<SelectLinkButton to={`${base}/scores/history`}>
|
||||
<Icon type="history" />
|
||||
Recent Scores
|
||||
</SelectButton>
|
||||
<SelectButton id="playcount" setValue={setScoreSet} value={scoreSet}>
|
||||
</SelectLinkButton>
|
||||
<SelectLinkButton to={`${base}/scores/most-played`}>
|
||||
<Icon type="mortar-pestle" />
|
||||
Most Played
|
||||
</SelectButton>
|
||||
<SelectButton id="all" setValue={setScoreSet} value={scoreSet}>
|
||||
</SelectLinkButton>
|
||||
<SelectLinkButton to={`${base}/scores/all`}>
|
||||
<Icon type="database" />
|
||||
All PBs
|
||||
</SelectButton>
|
||||
</SelectLinkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 mt-4">
|
||||
{scoreSet === "best" ? (
|
||||
<>
|
||||
<Switch>
|
||||
<Route exact path="/dashboard/users/:userID/games/:game/:playtype/scores">
|
||||
<>
|
||||
<PBsOverview
|
||||
url={`/users/${reqUser.id}/games/${game}/${playtype}/pbs/best?alg=${alg}`}
|
||||
{...{ reqUser, game, playtype, alg }}
|
||||
/>
|
||||
{gptConfig.scoreRatingAlgs.length > 1 && (
|
||||
<AlgSelector {...{ alg, setAlg, gptConfig }} />
|
||||
)}
|
||||
</>
|
||||
</Route>
|
||||
<Route path="/dashboard/users/:userID/games/:game/:playtype/scores/history">
|
||||
<ScoresOverview {...{ reqUser, game, playtype }} />
|
||||
</Route>
|
||||
<Route path="/dashboard/users/:userID/games/:game/:playtype/scores/all">
|
||||
<PBsOverview
|
||||
url={`/users/${reqUser.id}/games/${game}/${playtype}/pbs/best?alg=${alg}`}
|
||||
{...{ reqUser, game, playtype, alg }}
|
||||
url={`/users/${reqUser.id}/games/${game}/${playtype}/pbs/all`}
|
||||
reqUser={reqUser}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
indexCol={false}
|
||||
/>
|
||||
{gptConfig.scoreRatingAlgs.length > 1 && (
|
||||
<AlgSelector {...{ alg, setAlg, gptConfig }} />
|
||||
)}
|
||||
</>
|
||||
) : scoreSet === "recent" ? (
|
||||
<ScoresOverview {...{ reqUser, game, playtype }} />
|
||||
) : scoreSet === "all" ? (
|
||||
<PBsOverview
|
||||
url={`/users/${reqUser.id}/games/${game}/${playtype}/pbs/all`}
|
||||
reqUser={reqUser}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
indexCol={false}
|
||||
/>
|
||||
) : (
|
||||
<PBsOverview
|
||||
url={`/users/${reqUser.id}/games/${game}/${playtype}/most-played`}
|
||||
reqUser={reqUser}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
indexCol
|
||||
showPlaycount
|
||||
/>
|
||||
)}
|
||||
</Route>
|
||||
<Route path="/dashboard/users/:userID/games/:game/:playtype/scores/most-played">
|
||||
<PBsOverview
|
||||
url={`/users/${reqUser.id}/games/${game}/${playtype}/most-played`}
|
||||
reqUser={reqUser}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
indexCol
|
||||
showPlaycount
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import SelectButton from "components/util/SelectButton";
|
||||
import { UGPTSettingsContext } from "context/UGPTSettingsContext";
|
||||
import deepmerge from "deepmerge";
|
||||
import { useFormik } from "formik";
|
||||
import React, { useContext, useState } from "react";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import {
|
||||
FormatGame,
|
||||
@@ -165,6 +165,29 @@ function ShowcaseForm({ reqUser, game, playtype }: Props) {
|
||||
const [stats, setStats] = useState(settings!.preferences.stats);
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const SaveChanges = async () => {
|
||||
await APIFetchV1(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/showcase`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(stats),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
true,
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
const [isFirstPaint, setIsFirstPaint] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstPaint) {
|
||||
setIsFirstPaint(false);
|
||||
} else {
|
||||
SaveChanges();
|
||||
}
|
||||
}, [stats]);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
{stats.length < 6 && (
|
||||
@@ -177,32 +200,15 @@ function ShowcaseForm({ reqUser, game, playtype }: Props) {
|
||||
</div>
|
||||
)}
|
||||
<RenderCurrentStats {...{ reqUser, game, playtype, stats, setStats }} />
|
||||
<div className="col-12 d-flex justify-content-center mt-4">
|
||||
<Button
|
||||
variant="success"
|
||||
onClick={async () => {
|
||||
await APIFetchV1(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/showcase`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(stats),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
true,
|
||||
true
|
||||
);
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
<UGPTStatCreator
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
show={show}
|
||||
setShow={setShow}
|
||||
reqUser={reqUser}
|
||||
onCreate={stat => setStats([...stats, stat])}
|
||||
onCreate={stat => {
|
||||
setStats([...stats, stat]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
import { ResponsiveBar } from "@nivo/bar";
|
||||
import { BarChartTooltip } from "components/charts/ChartTooltip";
|
||||
import Card from "components/layout/page/Card";
|
||||
import MiniTable from "components/tables/components/MiniTable";
|
||||
import DebounceSearch from "components/util/DebounceSearch";
|
||||
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 SelectButton from "components/util/SelectButton";
|
||||
import useUGPTBase from "components/util/useUGPTBase";
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { FolderDocument, GetGamePTConfig, integer, PublicUserDocument } from "tachi-common";
|
||||
import { UGPTFolderSearch, FolderStatsInfo } from "types/api-returns";
|
||||
import { GamePT } from "types/react";
|
||||
import { ChangeOpacity } from "util/color-opacity";
|
||||
import { TACHI_CHART_THEME } from "util/constants/chart-theme";
|
||||
import { Reverse, StepFromToMax, PercentFrom } from "util/misc";
|
||||
|
||||
type Props = { reqUser: PublicUserDocument } & GamePT;
|
||||
|
||||
export default function FoldersSearch({ reqUser, game, playtype }: Props) {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const params = useMemo(() => new URLSearchParams({ search }), [search]);
|
||||
|
||||
const { data, isLoading, error } = useApiQuery<UGPTFolderSearch>(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/folders?${params.toString()}`
|
||||
);
|
||||
|
||||
let body = <></>;
|
||||
|
||||
if (error) {
|
||||
body = <>{error.description}</>;
|
||||
} else if (isLoading || !data) {
|
||||
body = <Loading />;
|
||||
} else {
|
||||
const statMap = new Map();
|
||||
|
||||
for (const stat of data.stats) {
|
||||
statMap.set(stat.folderID, stat);
|
||||
}
|
||||
|
||||
body = (
|
||||
<>
|
||||
{data.folders.length === 0 && (
|
||||
<div className="col-12 text-center">Found nothin'.</div>
|
||||
)}
|
||||
{data.folders.map(e => (
|
||||
<FolderInfoComponent
|
||||
key={e.folderID}
|
||||
folder={e}
|
||||
folderStats={statMap.get(e.folderID)!}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-12">
|
||||
<DebounceSearch
|
||||
className="form-control-lg"
|
||||
setSearch={setSearch}
|
||||
placeholder="Search all Folders..."
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 mt-8">
|
||||
<div className="row">{search !== "" && body}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderInfoComponent({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
folderStats,
|
||||
folder,
|
||||
}: Props & { folder: FolderDocument; folderStats: FolderStatsInfo }) {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const [elements, setElements] = useState<"grade" | "lamp">(gptConfig.scoreBucket);
|
||||
|
||||
const base = useUGPTBase({ reqUser, game, playtype });
|
||||
|
||||
const dataset = useMemo(() => {
|
||||
if (elements === "grade") {
|
||||
return (
|
||||
<DistributionTable
|
||||
colours={gptConfig.gradeColours}
|
||||
keys={Reverse(gptConfig.grades)}
|
||||
values={folderStats.grades}
|
||||
max={folderStats.chartCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DistributionTable
|
||||
colours={gptConfig.lampColours}
|
||||
keys={Reverse(gptConfig.lamps)}
|
||||
values={folderStats.lamps}
|
||||
max={folderStats.chartCount}
|
||||
/>
|
||||
);
|
||||
}, [elements]);
|
||||
|
||||
return (
|
||||
<div className="col-12 col-lg-6 mb-4">
|
||||
<Card
|
||||
header={folder.title}
|
||||
footer={
|
||||
<div className="w-100 d-flex justify-content-center">
|
||||
<LinkButton
|
||||
to={`${base}/folders/${folder.folderID}`}
|
||||
className="btn-outline-info"
|
||||
>
|
||||
View
|
||||
</LinkButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="row text-center">
|
||||
<div className="col-12">
|
||||
<div className="btn-group">
|
||||
<SelectButton value={elements} setValue={setElements} id="grade">
|
||||
<Icon type="sort-alpha-up" />
|
||||
Grades
|
||||
</SelectButton>
|
||||
<SelectButton value={elements} setValue={setElements} id="lamp">
|
||||
<Icon type="lightbulb" />
|
||||
Lamps
|
||||
</SelectButton>
|
||||
</div>
|
||||
<Divider className="my-4" />
|
||||
{dataset}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DistributionTable<T extends string>({
|
||||
keys,
|
||||
values,
|
||||
colours,
|
||||
max,
|
||||
}: {
|
||||
keys: T[];
|
||||
values: Record<T, integer>;
|
||||
colours: Record<T, string>;
|
||||
max: integer;
|
||||
}) {
|
||||
const cumulativeValues: Record<T, integer> = {} as Record<T, integer>;
|
||||
|
||||
let total = 0;
|
||||
for (const k of keys) {
|
||||
total += values[k] ?? 0;
|
||||
cumulativeValues[k] = total;
|
||||
}
|
||||
|
||||
return (
|
||||
<MiniTable headers={["Value", "Count (Total)", "Thermometer"]}>
|
||||
{keys.map((k, i) => (
|
||||
<tr key={k}>
|
||||
<td style={{ backgroundColor: ChangeOpacity(colours[k], 0.15) }}>{k}</td>
|
||||
<td>
|
||||
{values[k] ?? 0} <Muted>({cumulativeValues[k]})</Muted>
|
||||
</td>
|
||||
{i === 0 && (
|
||||
<FolderThermometer
|
||||
keys={keys}
|
||||
values={values}
|
||||
max={max}
|
||||
colours={colours}
|
||||
/>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</MiniTable>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderThermometer<T extends string>({
|
||||
keys,
|
||||
values,
|
||||
max,
|
||||
colours,
|
||||
}: {
|
||||
keys: T[];
|
||||
values: Record<T, integer>;
|
||||
colours: Record<T, string>;
|
||||
max: integer;
|
||||
}) {
|
||||
return (
|
||||
<td rowSpan={keys.length} style={{ width: 200, height: 40 * keys.length }}>
|
||||
<ResponsiveBar
|
||||
keys={Reverse(keys)}
|
||||
data={[Object.assign({ id: "" }, values)]}
|
||||
theme={Object.assign({}, TACHI_CHART_THEME, { background: "#1c1c1c" })}
|
||||
// @ts-expect-error temp
|
||||
colors={k => ChangeOpacity(colours[k.id], 0.5)}
|
||||
// @ts-expect-error Keys are appended with "." for some reason.
|
||||
borderColor={k => colours[k.data.id]}
|
||||
labelTextColor="black"
|
||||
labelSkipHeight={12}
|
||||
maxValue={max}
|
||||
padding={0.33}
|
||||
borderWidth={1}
|
||||
valueScale={{ type: "linear" }}
|
||||
axisRight={{
|
||||
tickSize: 5,
|
||||
tickPadding: 5,
|
||||
tickValues: StepFromToMax(max),
|
||||
}}
|
||||
margin={{ right: 50, bottom: 10, top: 20 }}
|
||||
tooltip={d => (
|
||||
<BarChartTooltip
|
||||
point={d}
|
||||
renderFn={d => (
|
||||
<div className="w-100 text-center">
|
||||
{d.label}
|
||||
{d.value} ({PercentFrom(d.value, max)})
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
motionConfig="stiff"
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
import { ResponsiveBar } from "@nivo/bar";
|
||||
import { BarChartTooltip } from "components/charts/ChartTooltip";
|
||||
import Card from "components/layout/page/Card";
|
||||
import TachiTable from "components/tables/components/TachiTable";
|
||||
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 SelectButton from "components/util/SelectButton";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Form, InputGroup } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
FolderDocument,
|
||||
Game,
|
||||
GamePTConfig,
|
||||
GetGamePTConfig,
|
||||
PublicUserDocument,
|
||||
TableDocument,
|
||||
} from "tachi-common";
|
||||
import { FolderStatsInfo, UGPTTableReturns } from "types/api-returns";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { DEFAULT_BAR_PROPS } from "util/charts";
|
||||
import { ChangeOpacity } from "util/color-opacity";
|
||||
import { Reverse } from "util/misc";
|
||||
import { NumericSOV, StrSOV } from "util/sorts";
|
||||
|
||||
interface Props {
|
||||
reqUser: PublicUserDocument;
|
||||
game: Game;
|
||||
playtype: Playtype;
|
||||
}
|
||||
|
||||
export default function FolderTablePage({ reqUser, game, playtype }: Props) {
|
||||
const { data, isLoading, error } = useApiQuery<TableDocument[]>(
|
||||
`/games/${game}/${playtype}/tables`
|
||||
);
|
||||
|
||||
const [tableID, setTableID] = useState("");
|
||||
const [tableMap, setTableMap] = useState(new Map());
|
||||
|
||||
const table = useMemo(() => tableMap.get(tableID), [tableID, tableMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const newMap = new Map();
|
||||
for (const table of data) {
|
||||
newMap.set(table.tableID, table);
|
||||
}
|
||||
setTableMap(newMap);
|
||||
setTableID(data[0].tableID);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputGroup>
|
||||
<InputGroup.Prepend>
|
||||
<InputGroup.Text>Table</InputGroup.Text>
|
||||
</InputGroup.Prepend>
|
||||
<Form.Control
|
||||
as="select"
|
||||
size="lg"
|
||||
value={tableID}
|
||||
onChange={e => setTableID(e.target.value)}
|
||||
>
|
||||
{data.map(e => (
|
||||
<option key={e.tableID} value={e.tableID}>
|
||||
{e.title}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</InputGroup>
|
||||
<Divider className="my-4" />
|
||||
{table && <TableFolderViewer {...{ reqUser, game, playtype, table }} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface UGPTFolderStats {
|
||||
folder: FolderDocument;
|
||||
stats: FolderStatsInfo;
|
||||
}
|
||||
|
||||
function TableFolderViewer({ reqUser, game, playtype, table }: Props & { table: TableDocument }) {
|
||||
const { data, isLoading, error } = useApiQuery<UGPTTableReturns>(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/tables/${table.tableID}`
|
||||
);
|
||||
|
||||
const [dataMap, setDataMap] = useState<Map<string, UGPTFolderStats>>(new Map());
|
||||
const [hasLoadedFolderMap, setHasLoadedFolderMap] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const statMap = new Map();
|
||||
for (const stat of data.stats) {
|
||||
statMap.set(stat.folderID, stat);
|
||||
}
|
||||
|
||||
const newMap = new Map();
|
||||
for (const folder of data.folders) {
|
||||
const stats = statMap.get(folder.folderID)!;
|
||||
newMap.set(folder.folderID, { folder, stats });
|
||||
}
|
||||
setDataMap(newMap);
|
||||
setHasLoadedFolderMap(true);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isLoading || !data || !hasLoadedFolderMap) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableBarChart table={table} dataMap={dataMap} />
|
||||
<Divider className="my-4" />
|
||||
<TableFolderTable
|
||||
reqUser={reqUser}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
table={table}
|
||||
dataMap={dataMap}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFolderTable({
|
||||
table,
|
||||
dataMap,
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
}: {
|
||||
table: TableDocument;
|
||||
dataMap: Map<string, UGPTFolderStats>;
|
||||
} & Props) {
|
||||
const dataset = useMemo(() => {
|
||||
const arr = [];
|
||||
for (const folder of table.folders) {
|
||||
const data = dataMap.get(folder);
|
||||
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
arr.push(data);
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [dataMap, table]);
|
||||
|
||||
return (
|
||||
<TachiTable
|
||||
dataset={dataset}
|
||||
headers={[
|
||||
["Folder", "Folder", StrSOV(x => x.folder.title)],
|
||||
["Chart Count", "Charts", NumericSOV(x => x.stats.chartCount)],
|
||||
["Buttons", "Buttons"],
|
||||
]}
|
||||
entryName="Folders"
|
||||
searchFunctions={{
|
||||
name: x => x.folder.title,
|
||||
title: x => x.folder.title,
|
||||
charts: x => x.stats.chartCount,
|
||||
}}
|
||||
rowFunction={data => (
|
||||
<tr>
|
||||
<td>{data.folder.title}</td>
|
||||
<td>{data.stats.chartCount}</td>
|
||||
<td>
|
||||
<LinkButton
|
||||
to={`/dashboard/users/${reqUser.username}/games/${game}/${playtype}/folders/${data.folder.folderID}`}
|
||||
className="btn-info"
|
||||
>
|
||||
View Breakdown
|
||||
</LinkButton>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBarChart({
|
||||
table,
|
||||
dataMap,
|
||||
}: {
|
||||
table: TableDocument;
|
||||
dataMap: Map<string, UGPTFolderStats>;
|
||||
}) {
|
||||
const gptConfig = GetGamePTConfig(table.game, table.playtype);
|
||||
|
||||
const [mode, setMode] = useState<"grades" | "lamps">(
|
||||
gptConfig.scoreBucket === "grade" ? "grades" : "lamps"
|
||||
);
|
||||
|
||||
const colours = useMemo(() => {
|
||||
if (mode === "grades") {
|
||||
return gptConfig.gradeColours;
|
||||
}
|
||||
|
||||
return gptConfig.lampColours;
|
||||
}, [mode]);
|
||||
|
||||
const dataset = useMemo(() => {
|
||||
const arr = [];
|
||||
|
||||
for (const folderID of table.folders) {
|
||||
const data = dataMap.get(folderID)!;
|
||||
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const realData = {
|
||||
__chartCount: data.stats.chartCount,
|
||||
};
|
||||
|
||||
// this is a series of stupid hacks and i dont really care.
|
||||
for (const key in data.stats[mode]) {
|
||||
// @ts-expect-error stupid key stuff
|
||||
realData[key] = (100 * data.stats[mode][key]) / data.stats.chartCount;
|
||||
// @ts-expect-error stupid key stuff
|
||||
realData[`${key}-count`] = data.stats[mode][key];
|
||||
}
|
||||
|
||||
arr.push({
|
||||
folder: data.folder.title,
|
||||
...realData,
|
||||
});
|
||||
}
|
||||
|
||||
return arr.reverse();
|
||||
}, [dataMap, mode]);
|
||||
|
||||
console.log(dataset);
|
||||
|
||||
return (
|
||||
<Card header="Overview">
|
||||
<div className="row">
|
||||
<div className="col-12 d-flex justify-content-center">
|
||||
<div className="btn-group">
|
||||
<SelectButton value={mode} setValue={setMode} id="grades">
|
||||
<Icon type="sort-alpha-up" />
|
||||
Grades
|
||||
</SelectButton>
|
||||
<SelectButton value={mode} setValue={setMode} id="lamps">
|
||||
<Icon type="lightbulb" />
|
||||
Lamps
|
||||
</SelectButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<Divider className="my-4" />
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<div
|
||||
style={{
|
||||
height: dataset.length * 40,
|
||||
}}
|
||||
>
|
||||
{/* This hack is necessary because otherwise
|
||||
nivo gets caught in a render loop
|
||||
and dies */}
|
||||
{mode === "grades" ? (
|
||||
<OverviewBarChart
|
||||
key="grade"
|
||||
gptConfig={gptConfig}
|
||||
mode={mode}
|
||||
dataset={dataset}
|
||||
colours={colours}
|
||||
/>
|
||||
) : (
|
||||
<OverviewBarChart
|
||||
key="lamp"
|
||||
gptConfig={gptConfig}
|
||||
mode={mode}
|
||||
dataset={dataset}
|
||||
colours={colours}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewBarChart({
|
||||
gptConfig,
|
||||
mode,
|
||||
dataset,
|
||||
colours,
|
||||
}: {
|
||||
gptConfig: GamePTConfig;
|
||||
mode: "grades" | "lamps";
|
||||
dataset: any;
|
||||
colours: any;
|
||||
}) {
|
||||
const longestTitle = useMemo(
|
||||
() =>
|
||||
dataset.map((e: any) => e.folder).sort(NumericSOV<string>(x => x.length, true))[0] ?? 0,
|
||||
[dataset]
|
||||
);
|
||||
|
||||
console.log(longestTitle);
|
||||
|
||||
return (
|
||||
<ResponsiveBar
|
||||
indexBy="folder"
|
||||
tooltip={d => (
|
||||
<BarChartTooltip
|
||||
point={d}
|
||||
renderFn={d => (
|
||||
<div className="w-100 text-center">
|
||||
{d.label}
|
||||
<br />
|
||||
{d.formattedValue}
|
||||
<br />
|
||||
<Muted>
|
||||
({(d.data as any)[`${d.id}-count`] ?? 0} /{" "}
|
||||
{/* @ts-expect-error cba */}
|
||||
{d.data.__chartCount})
|
||||
</Muted>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
keys={Reverse(gptConfig[mode])}
|
||||
colors={k => ChangeOpacity(colours[k.id], 0.5)}
|
||||
borderColor={k => colours[k.data.id]}
|
||||
borderWidth={1}
|
||||
data={dataset}
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
valueFormat={v => `${v.toFixed(2)}%`}
|
||||
margin={{ left: 10 + longestTitle.length * 10, bottom: 50, top: 20, right: 20 }}
|
||||
layout="horizontal"
|
||||
axisBottom={{
|
||||
tickSize: 5,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
legendPosition: "middle",
|
||||
legendOffset: 32,
|
||||
format: v => `${v}%`,
|
||||
}}
|
||||
enableGridX
|
||||
enableGridY={false}
|
||||
{...DEFAULT_BAR_PROPS}
|
||||
labelSkipWidth={40}
|
||||
labelSkipHeight={undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import useSetSubheader from "components/layout/header/useSetSubheader";
|
||||
import Icon from "components/util/Icon";
|
||||
import SelectLinkButton from "components/util/SelectLinkButton";
|
||||
import useUGPTBase from "components/util/useUGPTBase";
|
||||
import React from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { FormatGame, GetGameConfig, PublicUserDocument } from "tachi-common";
|
||||
import { GamePT } from "types/react";
|
||||
import FolderSelectPage from "./FolderSelectPage";
|
||||
import FolderTablePage from "./FolderTablePage";
|
||||
|
||||
type Props = { reqUser: PublicUserDocument } & GamePT;
|
||||
|
||||
export default function FoldersMainPage({ reqUser, game, playtype }: Props) {
|
||||
const gameConfig = GetGameConfig(game);
|
||||
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Folders"],
|
||||
[reqUser, game, playtype],
|
||||
`${reqUser.username}'s ${FormatGame(game, playtype)} Folders`
|
||||
);
|
||||
|
||||
const base = useUGPTBase({ reqUser, game, playtype });
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-12 text-center">
|
||||
<div className="btn-group mb-4">
|
||||
<SelectLinkButton to={`${base}/folders`}>
|
||||
<Icon type="table" />
|
||||
Table View
|
||||
</SelectLinkButton>
|
||||
<SelectLinkButton to={`${base}/folders/search`}>
|
||||
<Icon type="search" />
|
||||
Folder Select
|
||||
</SelectLinkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<Switch>
|
||||
<Route exact path="/dashboard/users/:userID/games/:game/:playtype/folders">
|
||||
<FolderTablePage {...{ reqUser, game, playtype }} />
|
||||
</Route>
|
||||
<Route
|
||||
exact
|
||||
path="/dashboard/users/:userID/games/:game/:playtype/folders/search"
|
||||
>
|
||||
<FolderSelectPage {...{ reqUser, game, playtype }} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import RegisterPage from "app/pages/RegisterPage";
|
||||
import ForgotPasswordPage from "app/pages/ForgotPasswordPage";
|
||||
import ResetPasswordPage from "app/pages/ResetPasswordPage";
|
||||
import CenterPage from "components/util/CenterPage";
|
||||
import { ClientConfig } from "lib/config";
|
||||
import MainPageTitleContainer from "components/util/MainPageTitleContainer";
|
||||
|
||||
/**
|
||||
* Core Routes for Tachi-Client.
|
||||
@@ -20,10 +22,12 @@ export function Routes() {
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Redirect exact from="/" to="/dashboard" />
|
||||
<Route exact path="/">
|
||||
{!user && ClientConfig.MANDATE_LOGIN ? <LoginPage /> : <Redirect to="/dashboard" />}
|
||||
</Route>
|
||||
|
||||
<Route path="/dashboard">
|
||||
<DashboardRoutes />
|
||||
{!user && ClientConfig.MANDATE_LOGIN ? <LoginPage /> : <DashboardRoutes />}
|
||||
</Route>
|
||||
|
||||
<Route exact path="/login">
|
||||
@@ -43,7 +47,10 @@ export function Routes() {
|
||||
<Redirect to="/dashboard" />
|
||||
) : (
|
||||
<CenterPage>
|
||||
<b>You can't.</b> I specifically said not to do this!
|
||||
<MainPageTitleContainer
|
||||
title="You can't."
|
||||
desc="If you signed up with a fake email, you're now locked out of this account. Nice one."
|
||||
/>
|
||||
<span
|
||||
onClick={() => history.goBack()}
|
||||
tabIndex={4}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import PlaytypeSelect from "app/pages/dashboard/games/_game/PlaytypeSelect";
|
||||
import AchievablesPage from "app/pages/dashboard/users/games/_game/_playtype/AchievablesPage";
|
||||
import FoldersMainPage from "app/pages/dashboard/users/games/_game/_playtype/folders/FoldersMainPage";
|
||||
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";
|
||||
@@ -19,12 +21,12 @@ import React, { useContext, useEffect } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { Redirect, Route, Switch, useParams } from "react-router-dom";
|
||||
import {
|
||||
FormatGame,
|
||||
Game,
|
||||
GetGameConfig,
|
||||
PublicUserDocument,
|
||||
UserGameStats,
|
||||
UGPTSettings,
|
||||
FormatGame,
|
||||
UserGameStats,
|
||||
} from "tachi-common";
|
||||
import { UGPTStatsReturn } from "types/api-returns";
|
||||
import { APIFetchV1, APIFetchV1Return, ToAPIURL } from "util/api";
|
||||
@@ -167,8 +169,6 @@ function UserGamePlaytypeRoutes({ reqUser, game }: { reqUser: PublicUserDocument
|
||||
throw settingsRes;
|
||||
}
|
||||
|
||||
console.log(settingsRes.body);
|
||||
|
||||
setSettings(settingsRes.body);
|
||||
}
|
||||
})();
|
||||
@@ -226,17 +226,17 @@ function UserGamePlaytypeRoutes({ reqUser, game }: { reqUser: PublicUserDocument
|
||||
<Route exact path="/dashboard/users/:userID/games/:game/:playtype">
|
||||
<OverviewPage reqUser={reqUser} game={game} playtype={playtype} />
|
||||
</Route>
|
||||
<Route exact path="/dashboard/users/:userID/games/:game/:playtype/scores">
|
||||
<Route path="/dashboard/users/:userID/games/:game/:playtype/scores">
|
||||
<ScoresPage reqUser={reqUser} game={game} playtype={playtype} />
|
||||
</Route>
|
||||
<Route exact path="/dashboard/users/:userID/games/:game/:playtype/folders">
|
||||
<div>foo</div>
|
||||
<Route path="/dashboard/users/:userID/games/:game/:playtype/folders">
|
||||
<FoldersMainPage reqUser={reqUser} game={game} playtype={playtype} />
|
||||
</Route>
|
||||
<Route exact path="/dashboard/users/:userID/games/:game/:playtype/sessions">
|
||||
<SessionsPage reqUser={reqUser} game={game} playtype={playtype} />
|
||||
</Route>
|
||||
<Route exact path="/dashboard/users/:userID/games/:game/:playtype/achievables">
|
||||
<div>goalsandmilestones</div>
|
||||
<AchievablesPage reqUser={reqUser} game={game} playtype={playtype} />
|
||||
</Route>
|
||||
<Route exact path="/dashboard/users/:userID/games/:game/:playtype/leaderboard">
|
||||
<LeaderboardsPage reqUser={reqUser} game={game} playtype={playtype} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { PointTooltipProps } from "@nivo/line";
|
||||
import { BarTooltipProps } from "@nivo/bar";
|
||||
|
||||
const defaultRenderFn = (point: PointTooltipProps["point"]) => (
|
||||
<div>
|
||||
@@ -28,3 +29,32 @@ export default function ChartTooltip({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultBarRenderFn = (data: BarTooltipProps<unknown>) => (
|
||||
<div>
|
||||
{data.label} {data.value}
|
||||
</div>
|
||||
);
|
||||
|
||||
export function BarChartTooltip({
|
||||
renderFn = defaultBarRenderFn,
|
||||
point,
|
||||
}: {
|
||||
point: BarTooltipProps<unknown>;
|
||||
renderFn?: (p: BarTooltipProps<unknown>) => JSX.Element;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="chart-tooltip d-flex align-items-center justify-content-center"
|
||||
style={{
|
||||
padding: "1rem",
|
||||
backgroundColor: "#131313",
|
||||
borderRadius: "1px 1px 1px 1px",
|
||||
boxShadow: "0px 0px 5px 0px black",
|
||||
width: 200,
|
||||
}}
|
||||
>
|
||||
{renderFn(point)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function Footer() {
|
||||
Discord
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/zkldi/tachi-server"
|
||||
href="https://github.com/TeamNewGuys/tachi-server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="nav-link px-3"
|
||||
|
||||
@@ -6,26 +6,24 @@ import { HeaderMenu } from "./HeaderMenu";
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<>
|
||||
<div className="header header-fixed" id="kt_header">
|
||||
<div className="container d-flex align-items-stretch justify-content-between">
|
||||
<div className="d-flex align-items-stretch mr-3">
|
||||
<div className="header-logo">
|
||||
<Link to="/">
|
||||
<img
|
||||
className="logo-default max-h-40px"
|
||||
alt="Logo"
|
||||
src={toAbsoluteUrl("/media/logos/logo-mark.png")}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<HeaderMenu />
|
||||
<div className="header header-fixed" id="kt_header">
|
||||
<div className="container d-flex align-items-stretch justify-content-between">
|
||||
<div className="d-flex align-items-stretch mr-3">
|
||||
<div className="header-logo">
|
||||
<Link to="/">
|
||||
<img
|
||||
className="logo-default max-h-40px"
|
||||
alt="Logo"
|
||||
src={toAbsoluteUrl("/media/logos/logo-mark.png")}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Topbar />
|
||||
<HeaderMenu />
|
||||
</div>
|
||||
|
||||
<Topbar />
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { SongDocument, Game, GetGameConfig, integer, PublicUserDocument } from "tachi-common";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
SongDocument,
|
||||
Game,
|
||||
GetGameConfig,
|
||||
integer,
|
||||
PublicUserDocument,
|
||||
FormatGame,
|
||||
} from "tachi-common";
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { toAbsoluteUrl } from "_metronic/_helpers";
|
||||
import { JustChildren } from "types/react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { GamePT, JustChildren } from "types/react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import Divider from "components/util/Divider";
|
||||
import { NO_OP, PREVENT_DEFAULT } from "util/misc";
|
||||
import { PREVENT_DEFAULT } from "util/misc";
|
||||
import Loading from "components/util/Loading";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { TachiConfig } from "lib/config";
|
||||
import Icon from "components/util/Icon";
|
||||
import QuickTooltip from "../misc/QuickTooltip";
|
||||
|
||||
interface SearchReturns {
|
||||
users: PublicUserDocument[];
|
||||
@@ -29,6 +39,44 @@ function SearchResult({
|
||||
);
|
||||
}
|
||||
|
||||
function GPTSearchResult({
|
||||
user,
|
||||
game,
|
||||
playtype,
|
||||
tabIndex,
|
||||
}: {
|
||||
user: PublicUserDocument;
|
||||
tabIndex: integer;
|
||||
} & GamePT) {
|
||||
const currentLoc = window.location.href.split(`games/${game}/${playtype}`)[1];
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column ml-3 mt-2 mb-2">
|
||||
<strong>
|
||||
<Link to={`/dashboard/users/${user.username}`}>{user.username}</Link>
|
||||
<Link
|
||||
className="float-right gentle-link mr-4"
|
||||
tabIndex={tabIndex}
|
||||
to={`/dashboard/users/${user.username}/games/${game}/${playtype}${currentLoc}`}
|
||||
style={{
|
||||
fontWeight: "lighter",
|
||||
}}
|
||||
>
|
||||
<QuickTooltip
|
||||
text={`Go to ${user.username}'s ${FormatGame(
|
||||
game,
|
||||
playtype
|
||||
)} profile, if they have one.`}
|
||||
>
|
||||
<Icon type="exchange-alt" />
|
||||
</QuickTooltip>
|
||||
</Link>
|
||||
</strong>
|
||||
<span className="font-size-sm font-weight-bold text-muted">{user.status}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResults({ results }: { results: SearchReturns }) {
|
||||
if (!results.users.length && !results.songs.length) {
|
||||
return (
|
||||
@@ -38,6 +86,22 @@ function SearchResults({ results }: { results: SearchReturns }) {
|
||||
);
|
||||
}
|
||||
|
||||
// useParams doesn't do what i think it does, so...
|
||||
// here's a hack.
|
||||
const params = useLocation<{ game?: Game; playtype?: Playtype }>();
|
||||
|
||||
const { game, playtype } = useMemo(() => {
|
||||
const regexp = `/games/(${TachiConfig.supportedGames.join("|")})/[^/]*/?`;
|
||||
|
||||
if (params.pathname.match(new RegExp(regexp, "u"))) {
|
||||
const hack = params.pathname.split("/games/")[1].split("/");
|
||||
|
||||
return { game: hack[0] as Game, playtype: hack[1] as Playtype };
|
||||
}
|
||||
|
||||
return { game: null, playtype: null };
|
||||
}, [params.pathname]);
|
||||
|
||||
return (
|
||||
<div className="quick-search-result">
|
||||
{results?.users.length ? (
|
||||
@@ -46,18 +110,28 @@ function SearchResults({ results }: { results: SearchReturns }) {
|
||||
Users
|
||||
</div>
|
||||
<div>
|
||||
{results.users.map(u => (
|
||||
<SearchResult
|
||||
key={u.id}
|
||||
link={`/dashboard/users/${u.username}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<strong>{u.username}</strong>
|
||||
<span className="font-size-sm font-weight-bold text-muted">
|
||||
{u.status}
|
||||
</span>
|
||||
</SearchResult>
|
||||
))}
|
||||
{results.users.map(u =>
|
||||
game && playtype ? (
|
||||
<GPTSearchResult
|
||||
key={u.id}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
tabIndex={0}
|
||||
user={u}
|
||||
/>
|
||||
) : (
|
||||
<SearchResult
|
||||
key={u.id}
|
||||
link={`/dashboard/users/${u.username}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<strong>{u.username}</strong>
|
||||
<span className="font-size-sm font-weight-bold text-muted">
|
||||
{u.status}
|
||||
</span>
|
||||
</SearchResult>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<Divider className="mt-2 mb-4" />
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { CSSProperties } from "react";
|
||||
import { JustChildren } from "types/react";
|
||||
|
||||
export default function Card({
|
||||
@@ -6,13 +6,15 @@ export default function Card({
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
header: JSX.Element | string;
|
||||
footer?: JSX.Element | string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
} & JustChildren) {
|
||||
return (
|
||||
<div className={`card card-custom ${className}`}>
|
||||
<div className={`card card-custom ${className ? className : ""}`} style={style}>
|
||||
<div className="card-header">
|
||||
{typeof header === "string" ? (
|
||||
<h3 className="text-center mb-0">{header}</h3>
|
||||
|
||||
@@ -17,13 +17,16 @@ export default function Navbar({ children }: { children: JSX.Element[] }) {
|
||||
const value = useMemo(() => {
|
||||
const loc = location.pathname.split(/([#?]|\/$)/u)[0];
|
||||
|
||||
const x = links.indexOf(loc);
|
||||
|
||||
if (x === -1) {
|
||||
return 0;
|
||||
let lidx = 0;
|
||||
for (let i = 0; i < links.length; i++) {
|
||||
const link = links[i];
|
||||
if (loc.startsWith(link)) {
|
||||
lidx = i;
|
||||
// break; THIS IS DELIBERATE TO AVOID / matching everything
|
||||
}
|
||||
}
|
||||
|
||||
return x;
|
||||
return lidx;
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,20 +8,25 @@ export default function MiniTable({
|
||||
children,
|
||||
headers,
|
||||
colSpan = 1,
|
||||
}: { className?: string; headers: string[]; colSpan?: integer | integer[] } & JustChildren) {
|
||||
}: { className?: string; headers?: string[]; colSpan?: integer | integer[] } & JustChildren) {
|
||||
return (
|
||||
<table
|
||||
className={`table table-hover table-striped table-vertical-center text-center ${className}`}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((e, i) => (
|
||||
<th colSpan={Array.isArray(colSpan) ? colSpan[i] : colSpan} key={nanoid()}>
|
||||
{e}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
{headers && (
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((e, i) => (
|
||||
<th
|
||||
colSpan={Array.isArray(colSpan) ? colSpan[i] : colSpan}
|
||||
key={nanoid()}
|
||||
>
|
||||
{e}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
@@ -34,6 +34,7 @@ import ScoreCell from "../cells/ScoreCell";
|
||||
import deepmerge from "deepmerge";
|
||||
import HasDevModeOn from "components/util/HasDevModeOn";
|
||||
import LampCell from "../cells/LampCell";
|
||||
import BMSDifficultyCell from "../cells/BMSDifficultyCell";
|
||||
|
||||
export default function GenericSessionDropdown({ data }: { data: SessionDataset[0] }) {
|
||||
const { isLoading, error, data: sessionData } = useQuery(
|
||||
@@ -49,7 +50,7 @@ export default function GenericSessionDropdown({ data }: { data: SessionDataset[
|
||||
}
|
||||
);
|
||||
|
||||
const [view, setView] = useState<"raises" | "highlights" | "graphs" | "debug">("graphs");
|
||||
const [view, setView] = useState<"raises" | "debug">("raises");
|
||||
|
||||
if (error) {
|
||||
return <>{(error as Error).message}</>;
|
||||
@@ -71,18 +72,10 @@ export default function GenericSessionDropdown({ data }: { data: SessionDataset[
|
||||
<DropdownStructure
|
||||
buttons={
|
||||
<>
|
||||
<SelectButton setValue={setView} value={view} id="graphs">
|
||||
<Icon type="chart-area" />
|
||||
Graphs
|
||||
</SelectButton>
|
||||
<SelectButton setValue={setView} value={view} id="raises">
|
||||
<Icon type="receipt" />
|
||||
Raises
|
||||
</SelectButton>
|
||||
<SelectButton setValue={setView} value={view} id="highlights">
|
||||
<Icon type="star" />
|
||||
Highlights
|
||||
</SelectButton>
|
||||
<HasDevModeOn>
|
||||
<SelectButton setValue={setView} value={view} id="debug">
|
||||
<Icon type="bug" />
|
||||
@@ -101,15 +94,20 @@ function SessionRaiseBreakdown({ sessionData }: { sessionData: SpecificSessionRe
|
||||
const game = sessionData.session.game;
|
||||
const playtype = sessionData.session.playtype;
|
||||
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
// todo infer default table
|
||||
// const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const [chartIDs, setChartIDs] = useState<string[] | null>([]);
|
||||
const [tableID, setTableID] = useState<string>("levels");
|
||||
const [tableID, setTableID] = useState<string | null>(null);
|
||||
const [tableFolders, setTableFolders] = useState<FolderDocument[]>([]);
|
||||
const [folder, setFolder] = useState<FolderDocument | null>(null);
|
||||
const [filter, setFilter] = useState<"folder" | "all" | "highlighted">("all");
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableID) {
|
||||
return;
|
||||
}
|
||||
|
||||
APIFetchV1(`/games/${game}/${playtype}/tables/${tableID}`).then(r => {
|
||||
if (!r.success) {
|
||||
throw new Error(r.description);
|
||||
@@ -148,6 +146,12 @@ function SessionRaiseBreakdown({ sessionData }: { sessionData: SpecificSessionRe
|
||||
return res.body;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (tableID === null && (data?.length ?? 0) > 0) {
|
||||
setTableID(data![0].tableID);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (error) {
|
||||
return <>{(error as Error).message}</>;
|
||||
}
|
||||
@@ -184,7 +188,6 @@ function SessionRaiseBreakdown({ sessionData }: { sessionData: SpecificSessionRe
|
||||
<div className="col-6">
|
||||
<select
|
||||
className="form-control"
|
||||
value={tableID}
|
||||
onChange={e => setTableID(e.target.value)}
|
||||
>
|
||||
{data.map(e => (
|
||||
@@ -428,7 +431,13 @@ function ElementStatTable({
|
||||
return (
|
||||
<>
|
||||
<TitleCell chart={chart} game={game} song={song} />
|
||||
<DifficultyCell chart={chart} game={game} />
|
||||
{game !== "bms" ? (
|
||||
<DifficultyCell chart={chart} game={game} />
|
||||
) : (
|
||||
<BMSDifficultyCell
|
||||
chart={chart as ChartDocument<"bms:7K" | "bms:14K">}
|
||||
/>
|
||||
)}
|
||||
{preScoreCell}
|
||||
<td>⟶</td>
|
||||
{type === "grade" ? (
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "tachi-common";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { GetPBs } from "util/data";
|
||||
import { UppercaseFirst } from "util/misc";
|
||||
import { NumericSOV, StrSOV } from "util/sorts";
|
||||
import { FormatDuration, FormatTime, MillisToSince } from "util/time";
|
||||
import IndexCell from "../cells/IndexCell";
|
||||
@@ -41,7 +42,7 @@ export default function GenericSessionTable({
|
||||
const headers: Header<SessionDataset[0]>[] = [
|
||||
["Name", "Name", StrSOV(x => x.name)],
|
||||
["Scores", "Scores", NumericSOV(x => x.scoreInfo.length)],
|
||||
[alg, alg, NumericSOV(x => x.calculatedData[alg] ?? 0)],
|
||||
[UppercaseFirst(alg), UppercaseFirst(alg), NumericSOV(x => x.calculatedData[alg] ?? 0)],
|
||||
["Duration", "Dur.", NumericSOV(x => x.timeEnded - x.timeStarted)],
|
||||
["Timestamp", "Timestamp", NumericSOV(x => x.timeStarted)],
|
||||
];
|
||||
@@ -81,6 +82,7 @@ export default function GenericSessionTable({
|
||||
pbRate: x => GetPBs(x.scoreInfo).length / x.scoreInfo.length,
|
||||
duration: x => (x.timeEnded - x.timeStarted) / (1000 * 60),
|
||||
timestamp: x => x.timeStarted,
|
||||
[alg]: x => x.calculatedData[alg] ?? 0,
|
||||
}}
|
||||
rowFunction={s => <Row data={s} key={s.sessionID} rating={alg} indexCol={indexCol} />}
|
||||
/>
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
import MiniTable from "components/tables/components/MiniTable";
|
||||
import Divider from "components/util/Divider";
|
||||
import React from "react";
|
||||
import {
|
||||
Game,
|
||||
ScoreCalculatedDataLookup,
|
||||
GetGamePTConfig,
|
||||
IDStrings,
|
||||
PublicUserDocument,
|
||||
FormatGame,
|
||||
} from "tachi-common";
|
||||
import { Game, GetGamePTConfig, PublicUserDocument } from "tachi-common";
|
||||
import { UGPTStatsReturn } from "types/api-returns";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { MillisToSince } from "util/time";
|
||||
import ProfilePicture from "./ProfilePicture";
|
||||
import Navbar from "components/nav/Navbar";
|
||||
import NavItem from "components/nav/NavItem";
|
||||
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";
|
||||
import { useProfileRatingAlg } from "components/util/useScoreRatingAlg";
|
||||
|
||||
export function UGPTHeaderBody({
|
||||
reqUser,
|
||||
@@ -53,7 +42,7 @@ export function UGPTHeaderBody({
|
||||
<div className="col-12 col-md-6 col-lg-3">
|
||||
<MiniTable className="table-sm text-center" headers={["Player Info"]} colSpan={2}>
|
||||
<tr>
|
||||
<td>Playcount</td>
|
||||
<td>Scores</td>
|
||||
<td>{stats.totalScores}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -114,7 +103,11 @@ export function UGPTBottomNav({
|
||||
];
|
||||
|
||||
if (isRequestedUser) {
|
||||
navItems.push(<NavItem to={`${baseUrl}/settings`}>Settings</NavItem>);
|
||||
navItems.push(
|
||||
<NavItem key="settings" to={`${baseUrl}/settings`}>
|
||||
Settings
|
||||
</NavItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function RankingData({
|
||||
|
||||
if (key !== alg) {
|
||||
extendData.push(
|
||||
<div className="col-12">
|
||||
<div key={key} className="col-12">
|
||||
<small className="text-muted">
|
||||
{key}: #{rankingData[key].ranking}/{rankingData[key].outOf}
|
||||
</small>
|
||||
|
||||
@@ -293,7 +293,7 @@ function StatDelta({
|
||||
return (
|
||||
<div className={`mt-2 text-${colour}`}>
|
||||
<span>
|
||||
You: {formattedV2} ({v2 > v1 ? `+${d}` : d})
|
||||
You: {formattedV2} ({v2 > v1 ? `+${d}` : v2 === v1 ? `±${d}` : d})
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from "react";
|
||||
import { UnsuccessfulAPIFetchResponse } from "util/api";
|
||||
|
||||
export default function ApiError({ error }: { error: UnsuccessfulAPIFetchResponse }) {
|
||||
return <div>An error has occured: {error.description}</div>;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import React, { forwardRef } from "react";
|
||||
import { SetState } from "types/react";
|
||||
|
||||
// todo: understand this file - it was leveraged from metronic and uses something weird.
|
||||
// it exists because we need to override reactbootstraps default of adding btn btn-primary to dropdowns
|
||||
@@ -16,10 +15,12 @@ const DropdownTopbarItemToggler = forwardRef((props, ref) => (
|
||||
props.onClick(e);
|
||||
}}
|
||||
>
|
||||
{/* @ts-expect-error see todo */}
|
||||
{props.children}
|
||||
</div>
|
||||
));
|
||||
|
||||
/* @ts-expect-error see todo */
|
||||
DropdownTopbarItemToggler.displayName = "DropdownTopbarItemToggler";
|
||||
|
||||
export default DropdownTopbarItemToggler;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import SiteWordmark from "./SiteWordmark";
|
||||
|
||||
export default function MainPageTitleContainer({ title, desc }: { title: string; desc: string }) {
|
||||
return (
|
||||
<>
|
||||
<SiteWordmark />
|
||||
<div className="text-center mb-10 mb-lg-20">
|
||||
<h3 className="font-size-h1">{title}</h3>
|
||||
<span className="font-weight-bold text-dark-50">{desc}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, { CSSProperties } from "react";
|
||||
import { integer } from "tachi-common";
|
||||
|
||||
export default function CustomProgressBar({
|
||||
animated,
|
||||
striped,
|
||||
style,
|
||||
max = 100,
|
||||
now = 0,
|
||||
}: {
|
||||
animated?: boolean;
|
||||
striped?: boolean;
|
||||
style?: CSSProperties;
|
||||
max?: integer;
|
||||
now?: integer;
|
||||
}) {
|
||||
return (
|
||||
<div className="progress">
|
||||
<div
|
||||
className={`progress-bar${animated ? " progress-bar-animated" : ""}${
|
||||
striped ? " progress-bar-striped" : ""
|
||||
}`}
|
||||
style={Object.assign(
|
||||
{
|
||||
width: `${(now / max) * 100}%`,
|
||||
},
|
||||
style
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { ButtonVariant } from "react-bootstrap/esm/types";
|
||||
import { JustChildren, SetState } from "types/react";
|
||||
import { DoesMatchRoute } from "util/routing";
|
||||
import LinkButton from "./LinkButton";
|
||||
|
||||
export default function SelectLinkButton({
|
||||
children,
|
||||
onVariant = "primary",
|
||||
offVariant = "outline-secondary",
|
||||
to,
|
||||
}: {
|
||||
onVariant?: ButtonVariant;
|
||||
offVariant?: ButtonVariant;
|
||||
to: string;
|
||||
} & JustChildren) {
|
||||
return (
|
||||
<LinkButton
|
||||
to={to}
|
||||
className={`btn-${DoesMatchRoute(window.location.href, to) ? onVariant : offVariant}`}
|
||||
>
|
||||
{children}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { TachiConfig } from "lib/config";
|
||||
import React from "react";
|
||||
import { toAbsoluteUrl } from "_metronic/_helpers";
|
||||
|
||||
export default function SiteWordmark() {
|
||||
export default function SiteWordmark({ width = "256px" }) {
|
||||
return (
|
||||
<div className="text-center mb-10 mb-lg-10">
|
||||
<img
|
||||
src={toAbsoluteUrl("/media/logos/logo-wordmark.png")}
|
||||
alt={TachiConfig.name}
|
||||
width="256px"
|
||||
width={width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMemo } from "react";
|
||||
import { PublicUserDocument } from "tachi-common";
|
||||
import { GamePT } from "types/react";
|
||||
|
||||
export default function useUGPTBase({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
}: { reqUser: PublicUserDocument } & GamePT) {
|
||||
return useMemo(() => `/dashboard/users/${reqUser.username}/games/${game}/${playtype}`, [
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
]);
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import "./_metronic/_assets/plugins/flaticon2/flaticon.css";
|
||||
|
||||
const { PUBLIC_URL } = process.env;
|
||||
|
||||
console.log(process.env);
|
||||
|
||||
// if (!PUBLIC_URL) {
|
||||
// throw new Error(
|
||||
// `No PUBLIC_URL was defined in environment. Have you forgot to set it in a .env file?`
|
||||
|
||||
@@ -30,3 +30,6 @@ if (mode === "ktchi") {
|
||||
|
||||
export const TachiConfig = conf;
|
||||
export const ColourConfig = colourConf;
|
||||
export const ClientConfig = {
|
||||
MANDATE_LOGIN: !!process.env.REACT_APP_MANDATE_LOGIN,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
SongDocument,
|
||||
PBScoreDocument,
|
||||
UGSRatingsLookup,
|
||||
Grades,
|
||||
Lamps,
|
||||
TableDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
export interface UGPTStatsReturn<I extends IDStrings = IDStrings> {
|
||||
@@ -93,3 +96,21 @@ export interface SongChartsSearch<I extends IDStrings = IDStrings> {
|
||||
songs: SongDocument<IDStringToGame[I]>[];
|
||||
charts: ChartDocument<I>[];
|
||||
}
|
||||
|
||||
export interface FolderStatsInfo<I extends IDStrings = IDStrings> {
|
||||
grades: Record<Grades[I], integer>;
|
||||
lamps: Record<Lamps[I], integer>;
|
||||
folderID: string;
|
||||
chartCount: integer;
|
||||
}
|
||||
|
||||
export interface UGPTFolderSearch {
|
||||
folders: FolderDocument[];
|
||||
stats: FolderStatsInfo[];
|
||||
}
|
||||
|
||||
export interface UGPTTableReturns {
|
||||
folders: FolderDocument[];
|
||||
stats: FolderStatsInfo[];
|
||||
table: TableDocument;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { TACHI_CHART_THEME } from "./constants/chart-theme";
|
||||
|
||||
export const DEFAULT_BAR_PROPS = {
|
||||
labelTextColor: "black",
|
||||
labelSkipHeight: 12,
|
||||
theme: TACHI_CHART_THEME,
|
||||
motionConfig: "stiff",
|
||||
};
|
||||
+43
-1
@@ -1,5 +1,5 @@
|
||||
import { Playtype } from "types/tachi";
|
||||
import { GetGamePTConfig, Game, ScoreCalculatedDataLookup, IDStrings } from "tachi-common";
|
||||
import { GetGamePTConfig, Game, ScoreCalculatedDataLookup, IDStrings, integer } from "tachi-common";
|
||||
|
||||
export function RFA<T>(arr: T[]): T {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
@@ -67,9 +67,51 @@ export function DelayedPageReload(delay = 300) {
|
||||
setTimeout(() => window.location.reload(), delay);
|
||||
}
|
||||
|
||||
export function ShortDelayify(fn: () => void, delay = 300) {
|
||||
setTimeout(fn, delay);
|
||||
}
|
||||
|
||||
export const FetchJSONBody = (json: unknown) => ({
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(json),
|
||||
});
|
||||
|
||||
// https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js
|
||||
// the developer of this has migrated everything to Force ES6 style modules,
|
||||
// which really really messes with a lot of the ecosystem.
|
||||
// shim.
|
||||
|
||||
export function EscapeStringRegexp(string: string) {
|
||||
if (typeof string !== "string") {
|
||||
throw new TypeError("Expected a string");
|
||||
}
|
||||
|
||||
// Escape characters with special meaning either inside or outside character sets.
|
||||
// Use a simple backslash escape when it's always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns' stricter grammar.
|
||||
return string.replace(/[|\\{}()[\]^$+*?.]/gu, "\\$&").replace(/-/gu, "\\x2d");
|
||||
}
|
||||
|
||||
export function Reverse<T>(arr: T[]): T[] {
|
||||
return arr.slice(0).reverse();
|
||||
}
|
||||
|
||||
export function PercentFrom(num: number, denom: number) {
|
||||
return `${((100 * num) / denom).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export function StepFromToMax(max: integer, step: integer = 50) {
|
||||
const arr = [];
|
||||
|
||||
for (let i = 0; i < max; i += step) {
|
||||
if (max - i < 5) {
|
||||
// skip adding things like 500 -> 505(max) as it looks bad.
|
||||
break;
|
||||
}
|
||||
arr.push(i);
|
||||
}
|
||||
|
||||
arr.push(max);
|
||||
return arr;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { EscapeStringRegexp } from "./misc";
|
||||
|
||||
export function DoesMatchRoute(str: string, route: string) {
|
||||
const comps = EscapeStringRegexp(route).split("/");
|
||||
|
||||
let regexStr = "";
|
||||
for (const comp of comps) {
|
||||
if (comp.startsWith(":")) {
|
||||
regexStr += "[^/]*/";
|
||||
} else {
|
||||
regexStr += `${comp}/`;
|
||||
}
|
||||
}
|
||||
|
||||
regexStr += "?$";
|
||||
|
||||
const regex = new RegExp(regexStr, "u");
|
||||
return !!str.match(regex);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { TachiConfig } from "lib/config";
|
||||
import { GetGameConfig } from "tachi-common";
|
||||
|
||||
export function GetSortedGPTs() {
|
||||
const arr = [];
|
||||
for (const game of TachiConfig.supportedGames) {
|
||||
const gameConfig = GetGameConfig(game);
|
||||
for (const playtype of gameConfig.validPlaytypes) {
|
||||
arr.push({ game, playtype });
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
@@ -57,6 +57,12 @@ export const loggedInSplashes = [
|
||||
"through ivy - out middle",
|
||||
"The graphics. That's what it's really about.",
|
||||
"you could be playing mario galaxy right now",
|
||||
"...When yours just crashes?",
|
||||
"My Beautiful Score Tracking Fantasy",
|
||||
"The Supercharged Score Tracker.",
|
||||
"Play ULTRAKILL.",
|
||||
"A Cutting-Edge Score Tracker (heh).",
|
||||
"Winnipeg is a warm, nice place to live.",
|
||||
];
|
||||
|
||||
// Stuff that's bad for the kids, you know?
|
||||
|
||||
Reference in New Issue
Block a user