Folder Update

This commit is contained in:
zkldi
2021-09-13 10:39:29 +01:00
parent a7f77bac43
commit 53375fd746
43 changed files with 1638 additions and 1101 deletions
+2 -1
View File
@@ -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",
+104 -877
View File
File diff suppressed because it is too large Load Diff
+68 -31
View File
@@ -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>
);
+147
View File
@@ -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} />;
}
+74 -2
View File
@@ -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>
);
@@ -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>
);
}
@@ -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}
/>
);
}
@@ -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>
);
}
+10 -3
View File
@@ -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}
+8 -8
View File
@@ -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"
+15 -17
View File
@@ -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" />
</>
+4 -2
View File
@@ -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>
+8 -5
View File
@@ -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} />}
/>
+7 -14
View File
@@ -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>
);
+6
View File
@@ -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 -2
View File
@@ -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,
]);
}
-2
View File
@@ -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?`
+3
View 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,
};
+21
View File
@@ -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;
}
+8
View File
@@ -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
View File
@@ -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;
}
+19
View File
@@ -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);
}
+14
View File
@@ -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;
}
+6
View File
@@ -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?