Score Table Tests

This commit is contained in:
zkldi
2021-07-15 22:22:33 +01:00
parent 30bda10a94
commit f4a5c39d62
42 changed files with 2208 additions and 664 deletions
+17 -17
View File
@@ -337,23 +337,23 @@ module.exports = function(webpackEnv) {
// First, run the linter.
// It's important to do this before Babel processes the JS.
{
// WARN: disabled js here - should be re-enabled at some point.
test: /\.(jsx|ts|tsx)$/,
enforce: "pre",
use: [
{
options: {
cache: true,
formatter: require.resolve("react-dev-utils/eslintFormatter"),
eslintPath: require.resolve("eslint"),
resolvePluginsRelativeTo: __dirname,
},
loader: require.resolve("eslint-loader"),
},
],
include: paths.appSrc,
},
// {
// // WARN: disabled js here - should be re-enabled at some point.
// test: /\.(jsx|ts|tsx)$/,
// enforce: "pre",
// use: [
// {
// options: {
// cache: true,
// formatter: require.resolve("react-dev-utils/eslintFormatter"),
// eslintPath: require.resolve("eslint"),
// resolvePluginsRelativeTo: __dirname,
// },
// loader: require.resolve("eslint-loader"),
// },
// ],
// include: paths.appSrc,
// },
{
// "oneOf" will traverse all following loaders until one will
// match the requirements. When no loader matches it will fall
+8 -8
View File
@@ -42,16 +42,16 @@
"@types/react-dom": "17.0.8",
"@types/react-router": "5.1.15",
"@types/react-router-dom": "5.1.7",
"@typescript-eslint/eslint-plugin": "^2.2.0",
"@typescript-eslint/parser": "^2.2.0",
"@typescript-eslint/eslint-plugin": "4.28.3",
"@typescript-eslint/parser": "4.28.3",
"apexcharts": "3.19.2",
"babel-eslint": "10.0.3",
"babel-jest": "^24.9.0",
"babel-loader": "8.0.6",
"babel-plugin-named-asset-import": "^0.3.4",
"babel-preset-react-app": "^9.0.2",
"babel-plugin-named-asset-import": "^0.3.7",
"babel-preset-react-app": "^9.1.2",
"bootstrap": "4.6.0",
"camelcase": "^5.2.0",
"camelcase": "^5.3.1",
"case-sensitive-paths-webpack-plugin": "2.2.0",
"clipboard-copy": "3.1.0",
"clsx": "1.1.0",
@@ -62,7 +62,7 @@
"dotenv-expand": "5.1.0",
"downshift": "3.4.2",
"eslint": "^7.30.0",
"eslint-config-react-app": "^5.0.2",
"eslint-config-react-app": "^5.2.1",
"eslint-loader": "3.0.2",
"eslint-plugin-flowtype": "3.13.0",
"eslint-plugin-import": "2.18.2",
@@ -72,7 +72,7 @@
"file-loader": "3.0.1",
"formik": "2.2.9",
"fs-extra": "7.0.1",
"html-webpack-plugin": "4.0.0-beta.5",
"html-webpack-plugin": "^4.5.2",
"identity-obj-proxy": "3.0.0",
"is-wsl": "^1.1.0",
"jest": "24.9.0",
@@ -92,7 +92,7 @@
"postcss-preset-env": "6.7.0",
"postcss-safe-parser": "4.0.1",
"react": "16.12.0",
"react-app-polyfill": "^1.0.4",
"react-app-polyfill": "^1.0.6",
"react-bootstrap": "1.0.1",
"react-bootstrap-table-next": "4.0.2",
"react-bootstrap-table2-paginator": "2.1.2",
+1135 -394
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -49,6 +49,7 @@
}
],
"@typescript-eslint/explicit-function-return-type": "off",
"react/display-name": "off",
"import/no-unresolved": "off",
"react/prop-types": "warn",
"require-await": "warn",
+2 -2
View File
@@ -1,11 +1,11 @@
import React from "react";
import { BrowserRouter } from "react-router-dom";
import { Routes } from "./AppRoutes";
import { Routes } from "./routes/AppRoutes";
import { MaterialThemeProvider } from "../_metronic/layout";
import { UserContextProvider } from "context/UserContext";
import { Toaster } from "react-hot-toast";
import { SubheaderContextProvider } from "context/SubheaderContext";
import { LoadingScreen } from "components/layout/LoadingScreen";
import { LoadingScreen } from "components/layout/screens/LoadingScreen";
import { QueryClientProvider, QueryClient } from "react-query";
const queryClient = new QueryClient();
-37
View File
@@ -1,37 +0,0 @@
import React from "react";
import { Route, Switch } from "react-router-dom";
import { Layout } from "../_metronic/layout";
import { DashboardPage } from "./pages/dashboard/DashboardPage";
import RequireAuthAsUserParam from "components/auth/RequireAuthAsUserParam";
import UserPage from "./pages/dashboard/users/UserPage";
import CreditsPage from "./pages/dashboard/misc/CreditsPage";
import IIDXScoreTable from "components/tables/IIDXPBTable";
import PBsPage from "./pages/dashboard/users/games/game/playtype/PBsPage";
export default function DashboardRoutes() {
return (
<Layout>
<Switch>
<Route exact path="/dashboard">
<DashboardPage />
</Route>
<Route exact path="/dashboard/credits">
<CreditsPage />
</Route>
<Route exact path="/dashboard/users/:userID">
<UserPage />
</Route>
<Route exact path="/dashboard/users/:userID/settings">
<RequireAuthAsUserParam>Settings Page</RequireAuthAsUserParam>
</Route>
<Route exact path="/dashboard/users/:userID/games/:game/:playtype">
<PBsPage />
</Route>
</Switch>
</Layout>
);
}
@@ -7,12 +7,10 @@ import { SubheaderContext } from "context/SubheaderContext";
export default function CreditsPage() {
const { setTitle, setBreadcrumbs } = useContext(SubheaderContext);
UpdateSubheader(["Credits"], setTitle, setBreadcrumbs);
const [alt, setAlt] = useState(false);
useEffect(() => {
UpdateSubheader(["Credits"], setTitle, setBreadcrumbs);
}, []);
function a(normal: JSX.Element | string, ...altStr: (JSX.Element | string)[]) {
return alt ? RFA(altStr) : normal;
}
@@ -1,40 +1,20 @@
import Loading from "components/util/Loading";
import { BackgroundContext } from "context/BackgroundContext";
import React, { useContext, useEffect, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import React, { useContext, useEffect } from "react";
import { PublicUserDocument } from "tachi-common";
import { APIFetchV1, ToAPIURL } from "util/api";
import { ToAPIURL } from "util/api";
import { UpdateSubheader } from "util/subheader";
import { SubheaderContext } from "context/SubheaderContext";
import Divider from "components/util/Divider";
export default function UserPage() {
export default function UserPage({ reqUser }: { reqUser: PublicUserDocument }) {
const { setBreadcrumbs, setTitle } = useContext(SubheaderContext);
const { setBackground } = useContext(BackgroundContext);
const { userID } = useParams<{ userID: string }>();
const history = useHistory();
const [reqUser, setReqUser] = useState<PublicUserDocument>();
useEffect(() => {
APIFetchV1<PublicUserDocument>(`/users/${userID}`).then(rj => {
if (!rj.success) {
history.push("/error");
return; // hack
}
setReqUser(rj.body);
});
}, []);
useEffect(() => {
if (!reqUser) {
return;
}
UpdateSubheader(["Users", `${reqUser.username}'s Profile`], setTitle, setBreadcrumbs);
setBackground(ToAPIURL(`/users/${userID}/banner`));
setBackground(ToAPIURL(`/users/${reqUser.id}/banner`));
return () => {
setBackground(null);
@@ -1,22 +1,57 @@
import IIDXPBTable from "components/tables/IIDXPBTable";
import Loading from "components/util/Loading";
import React, { useState } from "react";
import { BackgroundContext } from "context/BackgroundContext";
import { SubheaderContext } from "context/SubheaderContext";
import React, { useContext, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { useParams } from "react-router-dom";
import { PBScoreDocument, ChartDocument, SongDocument } from "tachi-common";
import {
PBScoreDocument,
ChartDocument,
SongDocument,
PublicUserDocument,
Playtypes,
Game,
GetGameConfig,
} from "tachi-common";
import { GamePT } from "types/react";
import { PBDataset } from "types/tables";
import { APIFetchV1 } from "util/api";
import { APIFetchV1, ToAPIURL } from "util/api";
import { UpdateSubheader } from "util/subheader";
export default function PBsPage() {
export default function PBsPage({
reqUser,
game,
playtype,
}: {
reqUser: PublicUserDocument;
} & GamePT) {
const [dataset, setDataset] = useState<PBDataset<"iidx:SP">>([]);
const { userID } = useParams<{ userID: string }>();
const { setTitle, setBreadcrumbs } = useContext(SubheaderContext);
const { setBackground } = useContext(BackgroundContext);
const { isLoading, error } = useQuery("TEMP_PBS", async () => {
const gameConfig = GetGameConfig(game);
useEffect(() => {
UpdateSubheader(
["Users", reqUser.username, gameConfig.name, playtype],
setTitle,
setBreadcrumbs,
`${reqUser.username}'s ${gameConfig.name} ${playtype} Profile`
);
setBackground(ToAPIURL(`/users/${reqUser.username}/banner`));
return () => {
setBackground(null);
};
}, [reqUser]);
const { isLoading, error } = useQuery(`${reqUser.id}_pbs`, async () => {
const res = await APIFetchV1<{
scores: PBScoreDocument<"iidx:SP">[];
charts: ChartDocument<"iidx:SP">[];
songs: SongDocument<"iidx">[];
}>(`/users/${userID}/games/iidx/SP/pbs/best`);
}>(`/users/${reqUser.username}/games/iidx/SP/pbs/best`);
if (!res.success) {
throw res;
@@ -33,11 +68,12 @@ export default function PBsPage() {
chartMap.set(chart.chartID, chart);
}
const data = res.body.scores.map(e => ({
const data = res.body.scores.map((e, i) => ({
...e,
__related: {
song: songMap.get(e.songID),
chart: chartMap.get(e.chartID),
index: i,
},
}));
@@ -2,9 +2,9 @@ import { UserContext } from "context/UserContext";
import React, { useContext } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import DashboardRoutes from "./DashboardRoutes";
import { ErrorPage } from "./pages/ErrorPage";
import LoginPage from "./pages/LoginPage";
import RegisterPage from "./pages/RegisterPage";
import { ErrorPage } from "app/pages/ErrorPage";
import LoginPage from "app/pages/LoginPage";
import RegisterPage from "app/pages/RegisterPage";
/**
* Core Routes for Tachi-Client.
+26
View File
@@ -0,0 +1,26 @@
import React from "react";
import { Route, Switch } from "react-router-dom";
import { Layout } from "_metronic/layout";
import { DashboardPage } from "../pages/dashboard/DashboardPage";
import CreditsPage from "../pages/dashboard/misc/CreditsPage";
import UserRoutes from "./UserRoutes";
export default function DashboardRoutes() {
return (
<Layout>
<Switch>
<Route exact path="/dashboard">
<DashboardPage />
</Route>
<Route exact path="/dashboard/credits">
<CreditsPage />
</Route>
<Route path="/dashboard/users/:userID">
<UserRoutes />
</Route>
</Switch>
</Layout>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { ErrorPage } from "app/pages/ErrorPage";
import RequireAuthAsUserParam from "components/auth/RequireAuthAsUserParam";
import React, { useEffect, useState } from "react";
import { Route, Switch, useParams } from "react-router-dom";
import { Game, PublicUserDocument } from "tachi-common";
import { APIFetchV1 } from "util/api";
import { IsSupportedGame, IsSupportedPlaytype } from "util/asserts";
import PBsPage from "../pages/dashboard/users/games/game/playtype/PBsPage";
import UserPage from "../pages/dashboard/users/UserPage";
export default function UserRoutes() {
const [reqUser, setReqUser] = useState<PublicUserDocument | null>(null);
const params = useParams<{ userID: string }>();
useEffect(() => {
(async () => {
const res = await APIFetchV1<PublicUserDocument>(`/users/${params.userID}`);
if (!res.success) {
console.error(res);
} else {
setReqUser(res.body);
}
})();
}, [params.userID]);
if (!reqUser) {
return null;
}
return (
<Switch>
<Route exact path="/dashboard/users/:userID">
<UserPage reqUser={reqUser} />
</Route>
<Route exact path="/dashboard/users/:userID/settings">
<RequireAuthAsUserParam>Settings Page</RequireAuthAsUserParam>
</Route>
<Route path="/dashboard/users/:userID/games/:game">
<UserGameRoutes reqUser={reqUser} />
</Route>
</Switch>
);
}
function UserGameRoutes({ reqUser }: { reqUser: PublicUserDocument }) {
const { game } = useParams<{ game: string }>();
if (!IsSupportedGame(game)) {
return <ErrorPage statusCode={404} customMessage="This game is not supported." />;
}
return (
<Switch>
<Route path="/dashboard/users/:userID/games/:game/:playtype">
<UserGamePlaytypeRoutes reqUser={reqUser} game={game} />
</Route>
</Switch>
);
}
function UserGamePlaytypeRoutes({ reqUser, game }: { reqUser: PublicUserDocument; game: Game }) {
const { playtype } = useParams<{ playtype: string }>();
if (!IsSupportedPlaytype(game, playtype)) {
return <ErrorPage statusCode={404} customMessage="This game is not supported." />;
}
return (
<Switch>
<Route exact path="/dashboard/users/:userID/games/:game/:playtype">
<PBsPage reqUser={reqUser} game={game} playtype={playtype} />
</Route>
</Switch>
);
}
@@ -0,0 +1,36 @@
import React from "react";
import { Link } from "react-router-dom";
import { toAbsoluteUrl } from "../../../_metronic/_helpers";
import { Topbar } from "./Topbar";
import { HeaderMenu } from "./HeaderMenu";
export function Header() {
return (
<>
<div className="header" 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")}
/>
<img
className="logo-sticky max-h-40px"
alt="Logo"
src={toAbsoluteUrl("/media/logos/logo-mark.png")}
/>
</Link>
</div>
<HeaderMenu />
</div>
<Topbar />
</div>
</div>
</>
);
}
@@ -0,0 +1,80 @@
import React, { useContext, useEffect } from "react";
import { toAbsoluteUrl } from "../../../_metronic/_helpers";
import Divider from "components/util/Divider";
import { UserContext } from "context/UserContext";
import { useQuery } from "react-query";
import { APIFetchV1 } from "util/api";
import { UserGameStats, FormatGame } from "tachi-common";
import MenuLink from "./MenuLink";
import MenuDropdown from "./MenuDropdown";
import Loading from "components/util/Loading";
import KTLayoutHeader from "_metronic/_assets/js/layout/base/header";
import KTLayoutHeaderMenu from "_metronic/_assets/js/layout/base/header-menu";
export function HeaderMenu() {
const { user } = useContext(UserContext);
const { isLoading, error, data } = useQuery("USER_GAME_STATS", async () => {
if (!user) {
return null;
}
const res = await APIFetchV1<UserGameStats[]>("/users/me/game-stats");
if (!res.success) {
throw res;
}
return res.body;
});
if (error) {
console.error(error);
}
useEffect(() => {
if (data) {
KTLayoutHeader.init("kt_header", "kt_header_mobile");
KTLayoutHeaderMenu.init("kt_header_menu", "kt_header_menu_wrapper");
}
}, [data]);
if (isLoading) {
return <Loading />;
}
return (
<div className="header-menu-wrapper header-menu-wrapper-left" id="kt_header_menu_wrapper">
<div
id="kt_header_menu"
className="header-menu header-menu-left header-menu-mobile header-menu-layout-default header-menu-root-arrow"
>
<div className="d-lg-none">
<img
src={toAbsoluteUrl("/media/logos/logo-wordmark.png")}
width="100%"
className="px-10 mt-4"
/>
<div className="mt-4">
<Divider />
</div>
</div>
<ul className="menu-nav">
<MenuLink name="Dashboard" to="/dashboard" />
{user && data && data.length !== 0 && (
<MenuDropdown name="My Games">
{data.map(e => (
<MenuLink
key={`${e.game}:${e.playtype}`}
name={FormatGame(e.game, e.playtype)}
to={`/dashboard/users/${user.username}/games/${e.game}/${e.playtype}`}
/>
))}
</MenuDropdown>
)}
</ul>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import React from "react";
import { JustChildren } from "types/react";
export default function MenuDropdown({ name, children }: { name: string } & JustChildren) {
return (
<li
data-menu-toggle="click"
aria-haspopup="true"
className="menu-item menu-item-submenu menu-item-rel menu-item-open-dropdown"
>
<div className="menu-link menu-toggle">
<span className="menu-text">{name}</span>
<i className="menu-arrow" />
</div>
<div className="menu-submenu menu-submenu-classic menu-submenu-left">
<ul className="menu-subnav">{children}</ul>
</div>
</li>
);
}
@@ -0,0 +1,12 @@
import React from "react";
import { NavLink } from "react-router-dom";
export default function MenuLink({ name, to }: { name: string; to: string }) {
return (
<li className="menu-item">
<NavLink className="menu-link" to={to}>
<span className="menu-text">{name}</span>
</NavLink>
</li>
);
}
@@ -0,0 +1,63 @@
import React, { useContext } from "react";
import SVG from "react-inlinesvg";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import { toAbsoluteUrl } from "../../../_metronic/_helpers";
import { SearchDropdown } from "../../../_metronic/layout/components/extras/dropdowns/search/SearchDropdown";
import { UserNotificationsDropdown } from "../../../_metronic/layout/components/extras/dropdowns/UserNotificationsDropdown";
import { UserProfileDropdown } from "components/layout/header/UserProfileDropdown";
import { UserContext } from "context/UserContext";
import LinkButton from "components/util/LinkButton";
export function Topbar() {
const { user } = useContext(UserContext);
return (
<div className="topbar">
<SearchDropdown />
{user ? (
<>
<UserNotificationsDropdown />
<OverlayTrigger
placement="bottom"
overlay={<Tooltip id="quick-panel-tooltip">Quick panel</Tooltip>}
>
<div
className="topbar-item"
data-toggle="tooltip"
title="Quick panel"
data-placement="right"
>
<div
className="btn btn-icon btn-hover-transparent-white btn-lg mr-1"
id="kt_quick_panel_toggle"
>
<span className="svg-icon svg-icon-xl">
<SVG
src={toAbsoluteUrl(
"/media/svg/icons/Layout/Layout-4-blocks.svg"
)}
/>
</span>
</div>
</div>
</OverlayTrigger>
<UserProfileDropdown user={user} />
</>
) : (
<>
<div className="topbar-item mr-3">
<LinkButton to="/login" className="btn-outline-primary">
Log In
</LinkButton>
</div>
<div className="topbar-item">
<LinkButton to="/register">Create Account</LinkButton>
</div>
</>
)}
</div>
);
}
@@ -1,7 +1,7 @@
import React, { useContext } from "react";
import { Link } from "react-router-dom";
import Dropdown from "react-bootstrap/Dropdown";
import { DropdownTopbarItemToggler } from "../../_metronic/_partials/dropdowns";
import { DropdownTopbarItemToggler } from "../../../_metronic/_partials/dropdowns";
import Divider from "components/util/Divider";
import { PublicUserDocument } from "tachi-common";
import { Button } from "react-bootstrap";
@@ -3,7 +3,7 @@ import React, { useContext, useState, useEffect } from "react";
import { PublicUserDocument } from "tachi-common";
import { JustChildren } from "types/react";
import { APIFetchV1 } from "util/api";
import { SplashScreen } from "_metronic/_partials/controls";
import { SplashScreen } from "./SplashScreen";
export function LoadingScreen({ children }: JustChildren) {
const [loading, setLoading] = useState(true);
@@ -1,6 +1,6 @@
import React from "react";
import { CircularProgress } from "@material-ui/core";
import { toAbsoluteUrl } from "../../_metronic/_helpers";
import { toAbsoluteUrl } from "_metronic/_helpers";
export function SplashScreen({ broke }: { broke: string }) {
return (
+78 -150
View File
@@ -1,173 +1,101 @@
import React, { useState } from "react";
import { FormatDifficulty } from "tachi-common/js/utils/util";
import { Button } from "react-bootstrap";
import { useZTable } from "components/util/table/useZTable";
import TitleCell from "./cells/TitleCell";
import RankingCell from "./cells/RankingCell";
import TimestampCell from "./cells/TimestampCell";
import BPICell from "./cells/BPICell";
import { ChangeOpacity } from "util/color-opacity";
import PageSelector from "./components/PageSelector";
import { NumericSOV, StrSOV } from "util/sorts";
import SortableTH from "./components/SortableTH";
import SelectableRating from "./components/SelectableRating";
import { PBDataset } from "types/tables";
import { GetGamePTConfig, ScoreCalculatedDataLookup } from "tachi-common";
import TachiTable from "./components/TachiTable";
import DifficultyCell from "./cells/DifficultyCell";
import ScoreCell from "./cells/ScoreCell";
import IndexCell from "./cells/IndexCell";
import { ComposeSearchFunction } from "util/ztable/search";
export default function IIDXPBTable({ dataset }: { dataset: PBDataset<"iidx:SP"> }) {
const [search, setSearch] = useState("");
const gptConfig = GetGamePTConfig<"iidx:SP">("iidx", "SP");
const [rating, setRating] = useState<ScoreCalculatedDataLookup["iidx:SP"]>(
gptConfig.defaultScoreRatingAlg
);
const {
window,
setPage,
pageState,
incrementPage,
decrementPage,
page,
maxPage,
displayStr,
sortMode,
reverseSort,
changeSort,
} = useZTable(dataset ?? [], {
search,
searchFunction: (s, pb) => !!pb.__related.song.title.match(new RegExp(s, "ui")),
entryName: "PBs",
sortFunctions: {
Difficulty: NumericSOV(x => x.__related.chart.levelNum),
Song: StrSOV(x => x.__related.song.title),
Score: NumericSOV(x => x.scoreData.percent),
Deltas: NumericSOV(x => x.scoreData.percent),
Lamp: NumericSOV(x => x.scoreData.lampIndex),
Ranking: NumericSOV(x => x.rankingData.rank),
Timestamp: NumericSOV(x => x.timeAchieved ?? 0),
},
});
const thProps = {
changeSort,
currentSortMode: sortMode,
reverseSort,
};
return (
<div className="row d-flex justify-content-center">
<div className="col-12">
<input onChange={e => setSearch(e.target.value)} type="text" value={search} />
</div>
<div className="col-12 mt-4 mb-4">
<table className="table table-striped table-hover table-vertical-center text-center">
<thead>
<tr>
<SortableTH name="Difficulty" {...thProps} />
<SortableTH style={{ width: "300px" }} name="Song" {...thProps} />
<SortableTH name="Score" {...thProps} />
<SortableTH name="Deltas" {...thProps} />
<SortableTH name="Lamp" {...thProps} />
<SelectableRating<"iidx:SP">
game="iidx"
playtype="SP"
setRating={setRating}
/>
<SortableTH name="Ranking" {...thProps} />
<SortableTH name="Timestamp" {...thProps} />
</tr>
</thead>
<tbody>
{window.map(pb => (
<tr key={pb.chartID}>
<td
style={{
backgroundColor: ChangeOpacity(
gptConfig.difficultyColours[
pb.__related.chart.difficulty
]!,
0.2
),
}}
>
{FormatDifficulty(pb.__related.chart, "iidx")}
</td>
<TitleCell
artist={pb.__related.song.artist}
title={pb.__related.song.title}
/>
<td
style={{
backgroundColor: ChangeOpacity(
gptConfig.gradeColours[pb.scoreData.grade],
0.2
),
}}
>
<strong>{pb.scoreData.grade}</strong>
<br />
{`${pb.scoreData.percent.toFixed(2)}%`}
<br />
<small className="text-muted">[{pb.scoreData.score}]</small>
</td>
<td>
<small className="text-muted">AAA-100</small>
<br />
<strong>AA+20</strong>
</td>
<td
style={{
backgroundColor: ChangeOpacity(
gptConfig.lampColours[pb.scoreData.lamp],
0.2
),
}}
>
<strong>{pb.scoreData.lamp}</strong>
<br />
<small>[BP: {pb.scoreData.hitMeta.bp ?? "No Data"}]</small>
</td>
{rating === "BPI" ? (
<BPICell bpi={pb.calculatedData.BPI} />
) : (
<td>
{pb.calculatedData[rating]
? pb.calculatedData[rating]!.toFixed(2)
: "No Data."}
</td>
)}
<RankingCell rankingData={pb.rankingData} />
<TimestampCell time={pb.timeAchieved} />
</tr>
))}
</tbody>
</table>
</div>
<div className="col-12 d-none d-lg-block">
<div className="row">
<div className="col-lg-6">{displayStr}</div>
<div className="col-lg-6 text-right">
<div className="btn-group">
<Button
variant="secondary"
disabled={pageState === "start" || pageState === "start-end"}
onClick={decrementPage}
>
Previous
</Button>
<PageSelector currentPage={page} maxPage={maxPage} setPage={setPage} />
<Button
variant="secondary"
disabled={pageState === "end" || pageState === "start-end"}
onClick={incrementPage}
>
Next
</Button>
</div>
</div>
</div>
</div>
</div>
<TachiTable
dataset={dataset}
headers={[
["#", "#", NumericSOV(x => x.__related.index)],
["Chart", "Ch.", NumericSOV(x => x.__related.chart.levelNum)],
["Song", "Song", StrSOV(x => x.__related.song.title)],
["Score", "Score", NumericSOV(x => x.scoreData.percent)],
["Deltas", "Deltas", NumericSOV(x => x.scoreData.percent)],
["Lamp", "Lamp", NumericSOV(x => x.scoreData.lampIndex)],
[
"Rating",
"Rating",
null,
() => (
<SelectableRating<"iidx:SP">
game="iidx"
playtype="SP"
setRating={setRating}
/>
),
],
["Ranking", "Rank", NumericSOV(x => x.rankingData.rank)],
["Timestamp", "Timestamp", NumericSOV(x => x.timeAchieved ?? 0)],
]}
entryName="PBs"
searchFunction={ComposeSearchFunction({
artist: x => x.__related.song.artist,
title: x => x.__related.song.title,
difficulty: x => FormatDifficulty(x.__related.chart, "iidx"),
level: x => x.__related.chart.levelNum,
score: x => x.scoreData.score,
percent: x => x.scoreData.percent,
lamp: x => x.scoreData.lamp,
grade: x => x.scoreData.grade,
})}
defaultSortMode="Rating"
rowFunction={pb => (
<tr key={pb.chartID}>
<IndexCell index={pb.__related.index} />
<DifficultyCell chart={pb.__related.chart} game={"iidx"} />
<TitleCell artist={pb.__related.song.artist} title={pb.__related.song.title} />
<ScoreCell pb={pb} game="iidx" playtype="SP" />
<td>
<small className="text-muted">AAA-100</small>
<br />
<strong>AA+20</strong>
</td>
<td
style={{
backgroundColor: ChangeOpacity(
gptConfig.lampColours[pb.scoreData.lamp],
0.2
),
}}
>
<strong>{pb.scoreData.lamp}</strong>
<br />
<small>[BP: {pb.scoreData.hitMeta.bp ?? "No Data"}]</small>
</td>
{rating === "BPI" ? (
<BPICell bpi={pb.calculatedData.BPI} />
) : (
<td>
{pb.calculatedData[rating]
? pb.calculatedData[rating]!.toFixed(2)
: "No Data."}
</td>
)}
<RankingCell rankingData={pb.rankingData} />
<TimestampCell time={pb.timeAchieved} />
</tr>
)}
/>
);
}
@@ -0,0 +1,5 @@
import React from "react";
export default function DeltaCell() {
return <div></div>;
}
@@ -0,0 +1,24 @@
import React from "react";
import {
AnyChartDocument,
FormatDifficulty,
FormatDifficultyShort,
Game,
GetGamePTConfig,
} from "tachi-common";
import { ChangeOpacity } from "util/color-opacity";
export default function DifficultyCell({ game, chart }: { game: Game; chart: AnyChartDocument }) {
const gptConfig = GetGamePTConfig(game, chart.playtype);
return (
<td
style={{
backgroundColor: ChangeOpacity(gptConfig.difficultyColours[chart.difficulty]!, 0.2),
}}
>
<span className="d-none d-lg-block">{FormatDifficulty(chart, game)}</span>
<span className="d-lg-none">{FormatDifficultyShort(chart, game)}</span>
</td>
);
}
@@ -0,0 +1,25 @@
import React from "react";
import { integer } from "tachi-common";
const COLORS = ["rgba(212,175,55,0.2)", "rgba(192,192,192,0.2)", "rgba(139,69,19,0.2)"];
export default function IndexCell({ index }: { index: integer }) {
return (
<td
style={{
backgroundColor: index < 3 ? COLORS[index] : undefined,
}}
>
<span className="text-muted" style={{ marginRight: "1px" }}>
#
</span>
<span
style={{
fontWeight: index < 10 ? "bold" : undefined,
}}
>
{index + 1}
</span>
</td>
);
}
@@ -0,0 +1,31 @@
import React from "react";
import { GetGamePTConfig, PBScoreDocument } from "tachi-common";
import { GamePT } from "types/react";
import { ChangeOpacity } from "util/color-opacity";
export default function ScoreCell({
game,
playtype,
pb,
showScore = true,
}: GamePT & { pb: PBScoreDocument; showScore?: boolean }) {
const gptConfig = GetGamePTConfig(game, playtype);
return (
<td
style={{
backgroundColor: ChangeOpacity(gptConfig.gradeColours[pb.scoreData.grade], 0.2),
}}
>
<strong>{pb.scoreData.grade}</strong>
<br />
{`${pb.scoreData.percent.toFixed(2)}%`}
{showScore && (
<>
<br />
<small className="text-muted">[{pb.scoreData.score}]</small>
</>
)}
</td>
);
}
@@ -2,7 +2,7 @@ import React from "react";
export default function TitleCell({ title, artist }: { title: string; artist: string }) {
return (
<td style={{ textAlign: "left" }}>
<td style={{ textAlign: "left", minWidth: "140px" }}>
{title}
<br />
<small>{artist}</small>
@@ -0,0 +1,15 @@
import React from "react";
export default function NoDataWrapper({ children }: { children: JSX.Element[] }) {
if (children.length === 0) {
return (
<tr>
{/* Slight hack - span atleast 100 columns, which is probably what we want. */}
<td colSpan={100}>
<span className="text-center">No Data.</span>
</td>
</tr>
);
}
return <>{children}</>;
}
@@ -7,13 +7,15 @@ export default function PageSelector({
setPage,
currentPage,
maxPage,
isSmall = false,
}: {
setPage: SetState<integer>;
currentPage: integer;
maxPage: integer;
isSmall?: boolean;
}) {
const elipseStart = currentPage > 4;
const elipseEnd = maxPage - currentPage > 3;
const elipseStart = currentPage > (isSmall ? 2 : 4);
const elipseEnd = maxPage - currentPage > (isSmall ? 1 : 3);
function PageButton({ page }: { page: integer }) {
return (
@@ -50,6 +52,32 @@ export default function PageSelector({
);
}
if (isSmall) {
return (
<>
<PageButton page={1} />
{elipseStart ? (
<Button disabled={true} variant="secondary">
...
</Button>
) : (
<PageButton page={2} />
)}
{middleNums.map(e => (
<PageButton key={e} page={e} />
))}
{elipseEnd ? (
<Button disabled={true} variant="secondary">
...
</Button>
) : (
<PageButton page={maxPage - 1} />
)}
<PageButton page={maxPage} />
</>
);
}
return (
<>
<PageButton page={1} />
@@ -1,8 +1,8 @@
import React, { HTMLAttributes } from "react";
import { SetState } from "types/react";
export default function SortableTH({
name,
shortName,
sortingName = name,
changeSort,
currentSortMode,
@@ -10,6 +10,7 @@ export default function SortableTH({
style = {},
}: {
name: string;
shortName: string;
sortingName?: string;
currentSortMode: string | null;
reverseSort: boolean;
@@ -17,8 +18,9 @@ export default function SortableTH({
style?: HTMLAttributes<HTMLTableCellElement>["style"];
}) {
return (
<th onClick={() => changeSort(sortingName)} style={style}>
<span className="mr-2">{name}</span>
<th className="compressible-th" onClick={() => changeSort(sortingName)} style={style}>
<span className="mr-2 d-none d-lg-block">{name}</span>
<span className="mr-2 d-block d-lg-none">{shortName}</span>
<span>
<i
className={`mr-1 flaticon2-arrow-up icon-sm sort-icon ${
@@ -0,0 +1,160 @@
import SmallText from "components/util/SmallText";
import { useZTable, ZTableSearchFn, ZTableSortFn } from "components/util/table/useZTable";
import React, { useState } from "react";
import { Button } from "react-bootstrap";
import { integer } from "tachi-common";
import NoDataWrapper from "./NoDataWrapper";
import PageSelector from "./PageSelector";
import SortableTH from "./SortableTH";
export interface ZTableTHProps {
changeSort: (str: string) => void;
currentSortMode: string | null;
reverseSort: boolean;
}
type NameHeader = [string, string];
type NameSortHeader<D> = [string, string, ZTableSortFn<D> | null];
type ComponentYielderHeader<D> = [
string,
string,
ZTableSortFn<D> | null,
(thProps: ZTableTHProps) => JSX.Element
];
// 0: name
// 1: sortFn
// 2: componentYielder -- yeah, i realise this format sucks.
export type Header<D> = NameHeader | NameSortHeader<D> | ComponentYielderHeader<D>;
function GetSortFunctions<D>(headers: Header<D>[]) {
const sortFunctions: Record<string, ZTableSortFn<D>> = {};
for (const header of headers) {
const [name, _shortName, sortFn] = header;
if (sortFn) {
sortFunctions[name] = sortFn;
}
}
return sortFunctions;
}
function ParseHeaders<D>(headers: Header<D>[], thProps: ZTableTHProps) {
const headerElements: JSX.Element[] = [];
for (const header of headers) {
const [name, shortName, sortFn, componentYielder] = header;
if (componentYielder) {
headerElements.push(componentYielder(thProps));
} else if (sortFn) {
headerElements.push(<SortableTH name={name} shortName={shortName} {...thProps} />);
} else {
headerElements.push(
<th>
<span className="d-none d-lg-block">{name}</span>
<span className="d-block d-lg-none">{shortName}</span>
</th>
);
}
}
return <tr>{headerElements}</tr>;
}
export default function TachiTable<D>({
dataset,
rowFunction,
headers,
entryName,
pageLen = 10,
defaultSortMode,
defaultReverseSort,
searchFunction,
}: {
dataset: D[];
rowFunction: (data: D) => JSX.Element;
headers: Header<D>[];
entryName?: string;
pageLen?: integer;
defaultSortMode?: string;
defaultReverseSort?: boolean;
searchFunction?: ZTableSearchFn<D>;
}) {
const [search, setSearch] = useState("");
const sortFunctions = GetSortFunctions(headers);
const {
window,
setPage,
pageState,
incrementPage,
decrementPage,
page,
maxPage,
displayStr,
sortMode,
reverseSort,
changeSort,
} = useZTable(dataset ?? [], {
search,
searchFunction,
sortFunctions,
entryName,
pageLen,
defaultSortMode,
defaultReverseSort,
});
const headersRow = ParseHeaders(headers, {
changeSort,
currentSortMode: sortMode,
reverseSort,
});
return (
<div className="justify-content-center w-100">
{searchFunction && (
<div className="col-12 px-0 ml-auto">
<input onChange={e => setSearch(e.target.value)} type="text" value={search} />
</div>
)}
<div className="col-12 px-0 mt-4 mb-4">
<table className="table table-striped table-hover table-vertical-center text-center table-responsive">
<thead>{headersRow}</thead>
<tbody>
<NoDataWrapper>{window.map(rowFunction)}</NoDataWrapper>
</tbody>
</table>
</div>
<div className="col-12 px-0">
<div className="row">
<div className="col-lg-6">{displayStr}</div>
<div className="col-lg-6 text-right">
<div className="btn-group">
<Button
variant="secondary"
disabled={pageState === "start" || pageState === "start-end"}
onClick={decrementPage}
>
<SmallText small="<" large="Previous" />
</Button>
<PageSelector currentPage={page} maxPage={maxPage} setPage={setPage} />
<Button
variant="secondary"
disabled={pageState === "end" || pageState === "start-end"}
onClick={incrementPage}
>
<SmallText small=">" large="Next" />
</Button>
</div>
</div>
</div>
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
export default function SmallText({ small, large }: { small: string; large: string }) {
return (
<>
<span className="d-none d-lg-block">{large}</span>
<span className="d-block d-lg-none">{small}</span>
</>
);
}
@@ -2,16 +2,17 @@ import { useMemo, useState } from "react";
import { integer } from "tachi-common";
import deepmerge from "deepmerge";
type SortFN<T> = (a: T, b: T) => integer;
export type ZTableSortFn<D> = (a: D, b: D) => integer;
export type ZTableSearchFn<D> = (search: string, data: D) => boolean;
interface ZTableOptions<D> {
pageLen: integer;
search: string;
searchFunction: (search: string, data: D) => boolean;
searchFunction: ZTableSearchFn<D>;
entryName: string;
defaultSortMode: string | null;
defaultReverseSort: boolean;
sortFunctions: Record<string, SortFN<D>>;
sortFunctions: Record<string, ZTableSortFn<D>>;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+21
View File
@@ -0,0 +1,21 @@
import { StaticConfig } from "tachi-common";
export const mode = process.env.REACT_APP_TCHIC_MODE;
if (!mode) {
throw new Error("No TCHIC_MODE set. Cannot boot.");
}
let conf;
if (mode === "ktchi") {
conf = StaticConfig.KTCHI_CONFIG;
} else if (mode === "btchi") {
conf = StaticConfig.BTCHI_CONFIG;
} else if (mode === "omni") {
conf = StaticConfig.OMNI_CONFIG;
} else {
throw new Error(`Invalid TCHIC_MODE of ${mode}. Cannot boot.`);
}
export const TachiConfig = conf;
+6
View File
@@ -9,6 +9,7 @@ import {
useFormik,
} from "formik";
import { Dispatch, SetStateAction } from "react";
import { Game, Playtypes } from "tachi-common";
export interface JustChildren {
children: JSX.Element[] | JSX.Element | string;
@@ -83,3 +84,8 @@ export type UseFormik<Values> = {
status?: any;
submitCount: number;
};
export interface GamePT {
game: Game;
playtype: Playtypes[Game];
}
+2 -1
View File
@@ -4,8 +4,9 @@ import {
ChartDocument,
SongDocument,
IDStringToGame,
integer,
} from "tachi-common";
export type PBDataset<I extends IDStrings> = (PBScoreDocument<I> & {
__related: { chart: ChartDocument<I>; song: SongDocument<IDStringToGame[I]> };
__related: { chart: ChartDocument<I>; song: SongDocument<IDStringToGame[I]>; index: integer };
})[];
+15
View File
@@ -0,0 +1,15 @@
import { TachiConfig } from "lib/config";
import { Game, GetGameConfig, Playtypes } from "tachi-common";
export function IsSupportedGame(str: string): str is Game {
return TachiConfig.supportedGames.includes((str as unknown) as Game);
}
export function IsSupportedPlaytype<G extends Game = Game>(
game: G,
str: string
): str is Playtypes[G] {
const gameConfig = GetGameConfig(game);
return gameConfig.validPlaytypes.includes((str as unknown) as Playtypes[G]);
}
@@ -0,0 +1,8 @@
export const SEARCH_DIRECTIVES = {
REGEX: "~",
EXACT: "!",
LESS_THAN: "<",
LESS_THAN_EQ: "<=",
GREATER_THAN: ">",
GREATER_THAN_EQ: ">=",
};
+83
View File
@@ -0,0 +1,83 @@
import { Game, GetGamePTConfig, Grades, IDStrings, integer, Playtypes } from "tachi-common";
export function AbsoluteGradeDelta<I extends IDStrings = IDStrings>(
game: Game,
playtype: Playtypes[Game],
score: number,
percent: number,
gradeOrIndex: Grades[I] | integer
) {
const gptConfig = GetGamePTConfig(game, playtype);
const maxScore = Math.floor(score * (percent / 100));
const gradeIndex =
typeof gradeOrIndex === "number" ? gradeOrIndex : gptConfig.grades.indexOf(gradeOrIndex);
const gradeScore = Math.floor(gptConfig.gradeBoundaries[gradeIndex] * maxScore);
return score - gradeScore;
}
export function RelativeGradeDelta<I extends IDStrings = IDStrings>(
game: Game,
playtype: Playtypes[Game],
score: number,
percent: number,
grade: Grades[I],
relativeIndex: integer
) {
const gptConfig = GetGamePTConfig(game, playtype);
const nextGradeIndex = gptConfig.grades.indexOf(grade) + relativeIndex;
if (nextGradeIndex < 0 || nextGradeIndex >= gptConfig.grades.length) {
return null;
}
return {
grade: gptConfig.grades[nextGradeIndex],
delta: AbsoluteGradeDelta(game, playtype, score, percent, nextGradeIndex),
};
}
function WrapGrade(grade: string) {
if (grade.endsWith("-") || grade.endsWith("+")) {
return `(${grade})`;
}
return grade;
}
export function GenericFormatGradeDelta<I extends IDStrings = IDStrings>(
game: Game,
playtype: Playtypes[Game],
score: number,
percent: number,
grade: Grades[I],
formatNumFn: (n: number) => number = s => s
): {
lower: string;
upper?: string;
closer: "lower" | "upper";
} {
const upper = RelativeGradeDelta(game, playtype, score, percent, grade, 1);
const lower = AbsoluteGradeDelta(game, playtype, score, percent, grade);
const formatLower = `${WrapGrade(grade)}+${formatNumFn(lower)}`;
if (!upper) {
return {
lower: formatLower,
closer: "lower",
};
}
const formatUpper = `${WrapGrade(upper.grade)}-${formatNumFn(upper.delta)}`;
return {
lower: formatLower,
upper: formatUpper,
closer: upper.delta - lower > 0 ? "lower" : "upper",
};
}
+3 -2
View File
@@ -3,9 +3,10 @@ import { SetState } from "types/react";
export function UpdateSubheader(
breadcrumbs: string[],
setTitle: SetState<string>,
setBreadcrumbs: SetState<string[]>
setBreadcrumbs: SetState<string[]>,
customTitle?: string
) {
const title = breadcrumbs[breadcrumbs.length - 1];
const title = customTitle ?? breadcrumbs[breadcrumbs.length - 1];
document.title = `${title} | Kamaitachi`;
setTitle(title);
+146
View File
@@ -0,0 +1,146 @@
import { ZTableSearchFn } from "components/util/table/useZTable";
import { SEARCH_DIRECTIVES } from "util/constants/search-directives";
export interface SearchDirective {
key: string;
option: string | null;
value: string;
}
type DirectiveModes = "!" | ">" | ">=" | "<" | "<=" | "~";
export interface Directive {
key: string;
// :! - EXACT
// :> - GREATER THAN
// :>= - GREATER THAN OR EQ
// :< - LESS THAN
// :<= - LESS THAN OR EQ
// :~ - REGEXP
mode: DirectiveModes | null;
value: string;
}
// Regex is an irritatingly write-only language, so i've split this one
// up.
// KEY = /([^ ]+):/ - Parses the key name
// MODE = /(>=|<=|!|>|<)?/ - parses the optional directive
// VALUE = /("(?:\\"|[^"])+"|[^ ]+)/ - Parses the value.
export function ParseDirectives(search: string): Directive[] {
const parsedSearch = search.matchAll(/([^ ]+):(>=|<=|!|>|<|~)?("(?:\\"|[^"])+"|[^ ]+)/gu);
const searchDirectives = [];
for (const ps of parsedSearch) {
if (ps.length !== 4) {
continue;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_wholeMatch, key, mode, value] = ps;
let v = value;
// strip surrounding quotes
if (value.startsWith('"') && value.endsWith('"')) {
v = value.substring(1, value.length - 1);
}
searchDirectives.push({
key: key.toLowerCase(),
mode: (mode || null) as Directive["mode"],
value: v,
});
}
return searchDirectives;
}
type MatchFn = (searchValue: string, dataValue: string | number) => boolean;
const Matchers: Record<DirectiveModes, MatchFn> = {
"!": (sv, dv) => sv === dv.toString(),
"<": (sv, dv) => {
if (typeof dv === "number") {
return dv < Number(sv);
}
return dv.localeCompare(sv) < 0;
},
"<=": (sv, dv) => {
if (typeof dv === "number") {
return dv <= Number(sv);
}
return dv.localeCompare(sv) <= 0 || dv.toLowerCase().startsWith(sv.toLowerCase());
},
">": (sv, dv) => {
if (typeof dv === "number") {
return dv > Number(sv);
}
return dv.localeCompare(sv) > 0;
},
">=": (sv, dv) => {
if (typeof dv === "number") {
return dv >= Number(sv);
}
return dv.localeCompare(sv) > 0 || dv.toLowerCase().startsWith(sv.toLowerCase());
},
"~": (sv, dv) => !!dv.toString().match(new RegExp(sv, "u"))?.length,
};
const NeutralMatch: MatchFn = (sv, dv) =>
dv
.toString()
.toLowerCase()
.includes(sv.toLowerCase());
function DirectiveMatch(directive: Directive, dataValue: string | number) {
if (!directive.mode) {
return NeutralMatch(directive.value, dataValue);
}
if (directive.mode === SEARCH_DIRECTIVES.REGEX) {
try {
return Matchers[SEARCH_DIRECTIVES.REGEX](directive.value, dataValue);
} catch {
// handle nonsense regex
return false;
}
}
return Matchers[directive.mode](directive.value, dataValue);
}
export function ComposeSearchFunction<D>(
valueGetters: Record<string, (data: D) => string | number>
): ZTableSearchFn<D> {
const allGetters = Object.values(valueGetters);
return (search, data) => {
const directives = ParseDirectives(search);
if (directives.length === 0) {
return allGetters.some(valueGetter => NeutralMatch(search, valueGetter(data)));
}
for (const directive of directives) {
const valueGetter = valueGetters[directive.key];
if (!valueGetter) {
continue;
}
const dataValue = valueGetter(data);
if (!DirectiveMatch(directive, dataValue)) {
return false;
}
}
return true;
};
}