feat: bunch of new stuff + proper dependency handling

This commit is contained in:
zkldi
2022-11-25 03:46:11 +00:00
parent cbce1a31f4
commit a6d89f748e
28 changed files with 567 additions and 232 deletions
@@ -1,5 +1,5 @@
import { APIFetchV1, UnsuccessfulAPIFetchResponse } from "util/api";
import { CreateUserMap } from "util/data";
import { CreateChartLink, CreateUserMap } from "util/data";
import { SelectRightChart } from "util/misc";
import { MillisToSince } from "util/time";
import useSetSubheader from "components/layout/header/useSetSubheader";
@@ -13,12 +13,11 @@ import ApiError from "components/util/ApiError";
import Icon from "components/util/Icon";
import Loading from "components/util/Loading";
import Muted from "components/util/Muted";
import SelectButton from "components/util/SelectButton";
import { UserContext } from "context/UserContext";
import React, { useContext, useMemo, useState } from "react";
import { Col, Row } from "react-bootstrap";
import { useQuery } from "react-query";
import { Link, useParams } from "react-router-dom";
import { Link, Route, Switch, useParams } from "react-router-dom";
import {
ChartDocument,
FormatDifficulty,
@@ -32,11 +31,17 @@ import {
import {
ChartPBLeaderboardReturn,
ChartRivalsReturn,
GoalsOnChartReturn,
UGPTChartLeaderboardAdjacent,
} from "types/api-returns";
import { GamePT, SetState } from "types/react";
import { PBDataset } from "types/tables";
import useLUGPTSettings from "components/util/useLUGPTSettings";
import TargetInfo from "components/tables/dropdowns/components/TargetInfo";
import useApiQuery from "components/util/query/useApiQuery";
import Divider from "components/util/Divider";
import { TargetsContext } from "context/TargetsContext";
import SelectLinkButton from "components/util/SelectLinkButton";
// This component forms a wrapper around the Real GPT Chart Page
// which handles the case where activeChart == null.
@@ -93,7 +98,9 @@ function InternalGPTChartPage({
} & GamePT) {
const { user } = useContext(UserContext);
const [mode, setMode] = useState<"leaderboard" | "adjacent" | "rivals">("leaderboard");
const [mode, setMode] = useState<"leaderboard" | "adjacent" | "rivals" | "targets">(
"leaderboard"
);
const { data, error } = useQuery<ChartPBData, UnsuccessfulAPIFetchResponse>(
["PBInfo", `${chart.chartID}`],
@@ -174,44 +181,108 @@ function InternalGPTChartPage({
userMap.set(user.id, user);
}
const base = CreateChartLink(chart, game);
return (
<Row>
<Col xs={12} className="text-center mb-2">
<h4 className="display-4">Leaderboard</h4>
</Col>
<Col xs={12} className="d-flex justify-content-center mb-8">
<div className="btn-group">
<SelectButton value={mode} setValue={setMode} id="leaderboard">
<Col xs={12} className="d-flex mb-8">
<div className="btn-group w-100">
<SelectLinkButton to={base}>
<Icon type="trophy" />
Best 100
</SelectButton>
<SelectButton
value={mode}
setValue={setMode}
id="adjacent"
disabled={!data.adjacent}
>
</SelectLinkButton>
<SelectLinkButton to={`${base}/me`} disabled={!data.adjacent}>
<Icon type="user" />
Your Position
</SelectButton>
</SelectLinkButton>
{user && (
<SelectButton value={mode} setValue={setMode} id="rivals">
<Icon type="user" />
VS. Rivals
</SelectButton>
<>
<SelectLinkButton to={`${base}/rivals`} disabled={!data.adjacent}>
<Icon type="users" />
Vs. Rivals
</SelectLinkButton>
<SelectLinkButton to={`${base}/targets`}>
<Icon type="scroll" />
Goals & Quests
</SelectLinkButton>
</>
)}
</div>
</Col>
<TopShowcase data={data} user={user} userMap={userMap} />
<Col xs={12} className="mt-4">
<ChartLeaderboardTable
{...{ data, game, playtype, user, userMap, chart, song, mode }}
/>
<Switch>
<Route
exact
path="/dashboard/games/:game/:playtype/songs/:songID/:difficulty/targets"
>
<ChartTargetInfo {...{ chart, game, playtype, song, user: user! }} />
</Route>
<Route path="/dashboard/games/:game/:playtype/songs/:songID/:difficulty">
<ChartLeaderboardTable
{...{
data,
game,
playtype,
user,
userMap,
chart,
song,
mode: mode as any,
}}
/>
</Route>
</Switch>
</Col>
</Row>
);
}
function ChartTargetInfo({
user,
game,
playtype,
chart,
song,
}: {
user: UserDocument;
chart: ChartDocument;
song: SongDocument;
} & GamePT) {
const { reloadTargets } = useContext(TargetsContext);
const [shouldReload, setShouldReload] = useState(0);
const { error, data } = useApiQuery<GoalsOnChartReturn>(
`/users/${user.id ?? ""}/games/${game}/${playtype}/targets/on-chart/${chart.chartID}`,
undefined,
// force a reload of this data when the user adds a new goal
[shouldReload.toString()]
);
return (
<Col xs={12} className="text-center">
<Divider />
<TargetInfo
{...{
chart,
data,
error,
game,
playtype,
reqUser: user,
song,
onGoalSet: () => {
// reload local query, then reload global targets.
setShouldReload(shouldReload + 1);
reloadTargets();
},
}}
/>
</Col>
);
}
function ChartLeaderboardTable({
data,
userMap,
@@ -325,21 +396,13 @@ function TopShowcase({
<PlayCard name="Best Play" pb={bestPlay} user={bestUser} />
</Col>
<Col xs={12} lg={6}>
<PlayCard name="Your Score" pb={thisUsersPlay} user={user!} />
<PlayCard name="Your PB" pb={thisUsersPlay} user={user!} />
</Col>
</>
);
}
function PlayCard({
pb,
user,
name,
}: {
pb: PBScoreDocument;
user: UserDocument;
name: string;
}) {
function PlayCard({ pb, user, name }: { pb: PBScoreDocument; user: UserDocument; name: string }) {
return (
<Card header={name}>
<Row className="align-items-center">
@@ -1,5 +1,4 @@
import { CreateGoalSubDataset, CreateUserMap } from "util/data";
import GoalSubTable from "components/tables/goals/GoalSubTable";
import SetNewGoalModal from "components/targets/SetNewGoalModal";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
@@ -10,6 +9,7 @@ import { Button, Col } from "react-bootstrap";
import { FolderDocument } from "tachi-common";
import { GoalsOnChartReturn, GoalsOnFolderReturn } from "types/api-returns";
import { UGPT } from "types/react";
import GoalSubInfo from "components/targets/GoalSubInfo";
export default function FolderQuestsPage({
folder,
@@ -68,7 +68,7 @@ function FolderQuestsInner({
const userMap = CreateUserMap([reqUser]);
return (
<GoalSubTable
<GoalSubInfo
dataset={CreateGoalSubDataset(data, userMap)}
game={game}
playtype={playtype}
@@ -1,6 +1,6 @@
import { ChangeAtPosition, DeleteInPosition } from "util/misc";
import useSetSubheader from "components/layout/header/useSetSubheader";
import EditableQuest from "components/quests/editor/EditableQuest";
import EditableQuest from "components/targets/quests/editor/EditableQuest";
import AddNewQuestModal from "components/targets/AddNewQuestModal";
import Divider from "components/util/Divider";
import Icon from "components/util/Icon";
+1 -1
View File
@@ -225,7 +225,7 @@ function SongChartRoutes({ game, playtype }: GamePT) {
/>
</Route>
<Route exact path="/dashboard/games/:game/:playtype/songs/:songID/:difficulty">
<Route path="/dashboard/games/:game/:playtype/songs/:songID/:difficulty">
<GPTChartPage
game={game}
playtype={playtype}
@@ -13,7 +13,7 @@ import { Col, Form, Row } from "react-bootstrap";
import { FormatGame, GetGameConfig, QuestDocument } from "tachi-common";
import { GamePT } from "types/react";
import { GPTQuestsReturn } from "types/api-returns";
import Quest from "components/quests/Quest";
import Quest from "components/targets/quests/Quest";
export default function QuestsPage({ game, playtype }: GamePT) {
useSetSubheader(
@@ -74,13 +74,13 @@ export default function ScoreDropdown<I extends IDStrings = IDStrings>({
const [shouldRefresh, forceRefresh] = useReducer((state) => state + 1, 0);
// when a user isn't logged in, skip ever making this request.
const { error: targetError, data: targetData } = useApiQuery<GoalsOnChartReturn>(
`/users/${currentUser?.id ?? ""}/games/${game}/${playtype}/targets/on-chart/${
chart.chartID
}`,
undefined,
[shouldRefresh],
// when a user isn't logged in, skip ever making this request.
currentUser === null
);
@@ -8,8 +8,10 @@ import React, { useState } from "react";
import { Button, Col } from "react-bootstrap";
import { GoalsOnChartReturn } from "types/api-returns";
import { UGPT } from "types/react";
import { ChartDocument, SongDocument } from "tachi-common";
import GoalSubTable from "components/tables/goals/GoalSubTable";
import { ChartDocument, FormatChart, SongDocument } from "tachi-common";
import Icon from "components/util/Icon";
import GoalSubInfo from "components/targets/GoalSubInfo";
import DebugContent from "components/util/DebugContent";
export default function TargetInfo({
data,
@@ -39,19 +41,21 @@ export default function TargetInfo({
const userMap = CreateUserMap([reqUser]);
const dataset = CreateGoalSubDataset(data, userMap);
return (
<div className="w-100">
<Col xs={12}>
<GoalSubTable
small
dataset={CreateGoalSubDataset(data, userMap)}
game={game}
playtype={playtype}
/>
<h1>Your Goals on {FormatChart(game, song, chart)}</h1>
<Divider />
</Col>
<Col xs={12}>
<GoalSubInfo dataset={dataset} game={game} playtype={playtype} />
</Col>
<Divider />
<Button variant="outline-success" onClick={() => setShow(true)}>
<Icon type="bullseye" />
Set New Goal
</Button>
<SetNewGoalModal
@@ -1,75 +0,0 @@
import { ChangeOpacity } from "util/color-opacity";
import { NumericSOV, StrSOV } from "util/sorts";
import React from "react";
import { COLOUR_SET } from "tachi-common";
import { GamePT } from "types/react";
import { GoalSubDataset } from "types/tables";
import TimestampCell from "../cells/TimestampCell";
import UserCell from "../cells/UserCell";
import TachiTable, { Header } from "../components/TachiTable";
import GoalSubTitleCell from "../cells/GoalSubTitleCell";
export default function GoalSubTable({
game,
playtype,
dataset,
showUser = false,
small = false,
}: GamePT & { dataset: GoalSubDataset; showUser?: boolean; small?: boolean }) {
const headers: Header<GoalSubDataset[0]>[] = [
["Goal", "Goal", StrSOV((x) => x.__related.goal.name)],
[
"Progress",
"Progress",
NumericSOV((x) => (x.progress === null ? -Infinity : x.progress / x.outOf)),
],
["Goal Set", "Goal Set", NumericSOV((x) => x.timeSet)],
];
if (showUser) {
headers.unshift(["User", "User", StrSOV((x) => x.__related.user.username)]);
}
return (
<TachiTable
dataset={dataset}
headers={headers}
entryName="Goals"
noTopDisplayStr={small}
searchFunctions={
!small
? {
user: (k) => k.__related.user.username,
goal: (k) => k.__related.goal.name,
timestamp: (k) => k.timeAchieved,
}
: undefined
}
rowFunction={(d) => (
<tr>
{showUser && (
<UserCell game={game} playtype={playtype} user={d.__related.user} />
)}
<GoalSubTitleCell game={game} playtype={playtype} data={d} />
<td
style={{
backgroundColor: d.achieved
? ChangeOpacity(COLOUR_SET.green, 0.2)
: ChangeOpacity(COLOUR_SET.red, 0.2),
}}
>
{d.achieved ? (
<b>Achieved!</b>
) : (
<>
<b>{d.progressHuman ?? "N/A"}</b>
<small>/{d.outOfHuman}</small>
</>
)}
</td>
<TimestampCell time={d.timeSet} />
</tr>
)}
/>
);
}
@@ -0,0 +1,74 @@
import Divider from "components/util/Divider";
import React, { useMemo, useState } from "react";
import { Col, Form } from "react-bootstrap";
import { GamePT } from "types/react";
import { GoalSubDataset } from "types/tables";
import { InnerQuestSectionGoal } from "./quests/Quest";
export default function GoalSubInfo({ dataset }: { dataset: GoalSubDataset } & GamePT) {
const [hideAchieved, setHideAchieved] = useState(false);
const { directGoals, folderGoals } = useMemo(() => {
let baseDataset = dataset;
if (hideAchieved) {
baseDataset = baseDataset.filter((e) => e.achieved === false);
}
return {
directGoals: baseDataset.filter(
(e) =>
e.__related.goal.charts.type === "single" ||
e.__related.goal.charts.type === "multi"
),
folderGoals: baseDataset.filter((e) => e.__related.goal.charts.type === "folder"),
};
}, [hideAchieved]);
return (
<>
<Col xs={12}>
<div className="pl-6">
<div className="d-flex w-100 justify-content-start">
<Form.Check
onChange={() => setHideAchieved(!hideAchieved)}
checked={hideAchieved}
label="Hide Achieved Goals"
/>
</div>
</div>
<Divider />
</Col>
<Col xs={12}>
{directGoals.length !== 0 && (
<div className="pl-6">
{directGoals.map((e, i) => (
<div className="pb-2 text-left" key={i}>
<InnerQuestSectionGoal
goal={e.__related.goal}
dependencies={e.__related.parentQuests.map((e) => e.name)}
/>
</div>
))}
</div>
)}
{folderGoals.length !== 0 && directGoals.length !== 0 && <Divider />}
{folderGoals.length !== 0 && (
<div className="pl-6">
{folderGoals.map((e, i) => (
<div className="pb-2 text-left" key={i}>
<InnerQuestSectionGoal
goal={e.__related.goal}
dependencies={e.__related.parentQuests.map((e) => e.name)}
/>
</div>
))}
</div>
)}
{folderGoals.length === 0 && directGoals.length === 0 && (
<div>You've got no goals related to this chart.</div>
)}
</Col>
</>
);
}
@@ -1,5 +1,6 @@
import { APIFetchV1 } from "util/api";
import { FormatTime } from "util/time";
import { HumanisedJoinArray } from "util/misc";
import Card from "components/layout/page/Card";
import Divider from "components/util/Divider";
import Icon from "components/util/Icon";
@@ -168,7 +169,15 @@ function QuestSectionComponent({
);
}
function InnerQuestSectionGoal({ goal, note }: { goal: GoalDocument; note?: string }) {
export function InnerQuestSectionGoal({
goal,
note,
dependencies,
}: {
goal: GoalDocument;
note?: string;
dependencies?: string[];
}) {
const { goalSubs } = useContext(TargetsContext);
const { game, playtype } = goal;
@@ -242,7 +251,30 @@ function InnerQuestSectionGoal({ goal, note }: { goal: GoalDocument; note?: stri
</div>
)}
</div>
{note && <Muted>{note}</Muted>}
<div>
{note && <Muted>{note}</Muted>}
{dependencies && (
<FormatGoalDependencies
isStandalone={goalSub.wasAssignedStandalone}
deps={dependencies}
/>
)}
</div>
</>
);
}
function FormatGoalDependencies({ deps, isStandalone }: { deps: string[]; isStandalone: boolean }) {
let str;
if (isStandalone && deps.length === 0) {
str = `Assigned standalone.`;
} else if (isStandalone && deps.length > 0) {
str = `Assigned standalone and from ${HumanisedJoinArray(deps, "and")}`;
} else if (deps.length === 0) {
return null;
} else {
str = `From ${HumanisedJoinArray(deps, "and")}`;
}
return <Muted>{str}</Muted>;
}
+8 -1
View File
@@ -8,14 +8,21 @@ export default function LinkButton({
children,
style,
onClick,
disabled = false,
}: {
to: string;
className?: string;
style?: CSSProperties;
onClick?: () => void;
disabled?: boolean;
} & JustChildren) {
return (
<Link onClick={onClick} to={to} className={`btn ${className}`} style={style}>
<Link
onClick={onClick}
to={to}
className={`btn ${disabled ? "disabled" : ""} ${className}`}
style={style}
>
{children}
</Link>
);
@@ -10,14 +10,17 @@ export default function SelectLinkButton({
offVariant = "outline-secondary",
to,
matchIfStartsWith = false,
disabled = false,
}: {
onVariant?: ButtonVariant;
offVariant?: ButtonVariant;
to: string;
matchIfStartsWith?: boolean;
disabled?: boolean;
} & JustChildren) {
return (
<LinkButton
disabled={disabled}
to={to}
className={`btn-${
DoesMatchRoute(window.location.href, to, !matchIfStartsWith)
+8
View File
@@ -563,3 +563,11 @@ export function ChangeAtPosition<T>(elements: T[], element: T, i: integer) {
export function DeleteInPosition<T>(elements: T[], i: integer) {
return [...elements.slice(0, i), ...elements.slice(i + 1)];
}
export function HumanisedJoinArray(arr: Array<string>, lastJoiner = "or") {
if (arr.length === 1) {
return arr[0];
}
return `${arr.slice(0, arr.length - 1).join(", ")} ${lastJoiner} ${arr[arr.length - 1]!}`;
}