mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-13 17:58:42 +03:00
Merge pull request #75 from TNG-dev/release-v2.0.2
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tachi-client",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node scripts/start.js -o",
|
||||
@@ -82,7 +82,7 @@
|
||||
"resolve": "1.12.0",
|
||||
"resolve-url-loader": "3.1.0",
|
||||
"socicon": "3.0.5",
|
||||
"tachi-common": "0.3.10"
|
||||
"tachi-common": "0.3.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/humanize-duration": "3.25.1",
|
||||
|
||||
Generated
+4
-4
@@ -84,7 +84,7 @@ specifiers:
|
||||
serve: 11.2.0
|
||||
socicon: 3.0.5
|
||||
style-loader: 1.0.0
|
||||
tachi-common: 0.3.10
|
||||
tachi-common: 0.3.19
|
||||
terser-webpack-plugin: 1.4.1
|
||||
ts-pnp: 1.1.4
|
||||
typescript: 4.3.5
|
||||
@@ -156,7 +156,7 @@ dependencies:
|
||||
resolve: 1.12.0
|
||||
resolve-url-loader: 3.1.0
|
||||
socicon: 3.0.5
|
||||
tachi-common: 0.3.10
|
||||
tachi-common: 0.3.19
|
||||
|
||||
devDependencies:
|
||||
'@types/humanize-duration': 3.25.1
|
||||
@@ -11241,8 +11241,8 @@ packages:
|
||||
strip-ansi: 6.0.0
|
||||
dev: true
|
||||
|
||||
/tachi-common/0.3.10:
|
||||
resolution: {integrity: sha512-OiEnRz8b7ix+m/W5bFwaN+0fgupmbYHKhsn4/IaFb8f2rCEHY/i3t8TGuYAx212Uo2hOsDSX5/yxVOrgcxZ1AA==}
|
||||
/tachi-common/0.3.19:
|
||||
resolution: {integrity: sha512-N3/n9emh2X9pA+3jQ1UfkLX9V8hk/oHGE1GPlkReRmyIR9gRAxhf3uZCiorPqibSkPzx9LrGLJcO52FR5NQ8oA==}
|
||||
dependencies:
|
||||
monk: 7.3.4
|
||||
transitivePeerDependencies:
|
||||
|
||||
@@ -79,7 +79,7 @@ function ProfileLeaderboard({ game, playtype }: GamePT) {
|
||||
) : null;
|
||||
|
||||
const { data, isLoading, error } = useApiQuery<UserLeaderboardReturns>(
|
||||
`/games/${game}/${playtype}/leaderboard?alg=${alg}`
|
||||
`/games/${game}/${playtype}/leaderboard?alg=${alg}&limit=500`
|
||||
);
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -175,7 +175,7 @@ function AchievedClassRow({
|
||||
playtype={playtype}
|
||||
classValue={classInfo.classValue}
|
||||
/>
|
||||
{classInfo.classOldValue && (
|
||||
{classInfo.classOldValue !== null && (
|
||||
<>
|
||||
{" "}
|
||||
(Raised from{" "}
|
||||
|
||||
@@ -172,6 +172,7 @@ function PreferencesForm({ reqUser }: { reqUser: PublicUserDocument }) {
|
||||
invisible: settings?.preferences.invisible ?? false,
|
||||
contentiousContent: settings?.preferences.contentiousContent ?? false,
|
||||
advancedMode: settings?.preferences.advancedMode ?? false,
|
||||
deletableScores: settings?.preferences.deletableScores ?? false,
|
||||
},
|
||||
onSubmit: async values => {
|
||||
const res = await APIFetchV1<UserSettings>(
|
||||
@@ -224,6 +225,19 @@ function PreferencesForm({ reqUser }: { reqUser: PublicUserDocument }) {
|
||||
/>
|
||||
<Form.Text>Hide your last seen status.</Form.Text>
|
||||
</Form.Group> */}
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
id="deletableScores"
|
||||
checked={formik.values.deletableScores}
|
||||
onChange={formik.handleChange}
|
||||
label="Deletable Scores"
|
||||
/>
|
||||
<Form.Text>
|
||||
Enables the option to delete scores. Turn this off if the thought of deleting a
|
||||
score gives you anxiety.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
COLOUR_SET,
|
||||
FormatGame,
|
||||
GetGameConfig,
|
||||
GetGamePTConfig,
|
||||
IDStrings,
|
||||
integer,
|
||||
PublicUserDocument,
|
||||
@@ -92,6 +93,8 @@ function LeaderboardsPageContent({
|
||||
} & GamePT) {
|
||||
const { stats, leaderboard } = data;
|
||||
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const userMap = new Map<integer, PublicUserDocument>();
|
||||
|
||||
for (const u of stats.users) {
|
||||
@@ -131,7 +134,13 @@ function LeaderboardsPageContent({
|
||||
{userMap.get(s.userID)?.username}
|
||||
</GentleLink>
|
||||
</td>
|
||||
<td>{(s.ratings[alg] ?? 0).toFixed(2)}</td>
|
||||
<td>
|
||||
{s.ratings[alg]
|
||||
? gptConfig.profileRatingAlgFormatters[alg]
|
||||
? gptConfig.profileRatingAlgFormatters[alg]!(s.ratings[alg]!)
|
||||
: s.ratings[alg]!.toFixed(2)
|
||||
: "No Data."}
|
||||
</td>
|
||||
{/* temp */}
|
||||
<td>
|
||||
{Object.entries(s.classes).length
|
||||
|
||||
@@ -10,6 +10,7 @@ import useQueryString from "components/util/useQueryString";
|
||||
import { UGPTSettingsContext } from "context/UGPTSettingsContext";
|
||||
import deepmerge from "deepmerge";
|
||||
import { useFormik } from "formik";
|
||||
import { TachiConfig } from "lib/config";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import {
|
||||
@@ -83,6 +84,7 @@ function PreferencesForm({ reqUser, game, playtype }: Props) {
|
||||
preferredSessionAlg:
|
||||
settings!.preferences.preferredSessionAlg || gptConfig.defaultSessionRatingAlg,
|
||||
gameSpecific: settings!.preferences.gameSpecific,
|
||||
scoreBucket: settings!.preferences.scoreBucket ?? gptConfig.scoreBucket,
|
||||
},
|
||||
onSubmit: async values => {
|
||||
const rj = await APIFetchV1<PublicUserDocument>(
|
||||
@@ -157,6 +159,24 @@ function PreferencesForm({ reqUser, game, playtype }: Props) {
|
||||
used for things like leaderboards.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Label>Preferred Score Info</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
id="scoreBucket"
|
||||
value={formik.values.scoreBucket}
|
||||
onChange={formik.handleChange}
|
||||
>
|
||||
<option value="grade">Score</option>
|
||||
<option value="lamp">Lamp</option>
|
||||
</Form.Control>
|
||||
<Form.Text className="text-muted">
|
||||
What should {TachiConfig.name} prefer to show you about scores?
|
||||
<br />
|
||||
Note: This will only affect defaults, such as what graph is shown in the folder
|
||||
breakdown. You can still view all the same stats!
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
{game === "iidx" && (
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
@@ -182,13 +202,13 @@ function PreferencesForm({ reqUser, game, playtype }: Props) {
|
||||
}
|
||||
|
||||
function ShowcaseForm({ reqUser, game, playtype }: Props) {
|
||||
const { settings } = useContext(UGPTSettingsContext);
|
||||
const { settings, setSettings } = useContext(UGPTSettingsContext);
|
||||
|
||||
const [stats, setStats] = useState(settings!.preferences.stats);
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const SaveChanges = async () => {
|
||||
await APIFetchV1(
|
||||
const r = await APIFetchV1<UGPTSettings>(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/showcase`,
|
||||
{
|
||||
method: "PUT",
|
||||
@@ -198,6 +218,10 @@ function ShowcaseForm({ reqUser, game, playtype }: Props) {
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
if (r.success) {
|
||||
setSettings(r.body);
|
||||
}
|
||||
};
|
||||
|
||||
const [isFirstPaint, setIsFirstPaint] = useState(true);
|
||||
|
||||
+5
-92
@@ -1,5 +1,6 @@
|
||||
import { ResponsiveBar } from "@nivo/bar";
|
||||
import { BarChartTooltip } from "components/charts/ChartTooltip";
|
||||
import DistributionTable from "components/game/folder/FolderDistributionTable";
|
||||
import Card from "components/layout/page/Card";
|
||||
import MiniTable from "components/tables/components/MiniTable";
|
||||
import DebounceSearch from "components/util/DebounceSearch";
|
||||
@@ -10,6 +11,7 @@ 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 { useBucket } from "components/util/useBucket";
|
||||
import useUGPTBase from "components/util/useUGPTBase";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import React, { useContext, useMemo, useState } from "react";
|
||||
@@ -89,7 +91,9 @@ export function FolderInfoComponent({
|
||||
}: Props & { folder: FolderDocument; folderStats: FolderStatsInfo }) {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const [elements, setElements] = useState<"grade" | "lamp">(gptConfig.scoreBucket);
|
||||
const scoreBucket = useBucket(game, playtype);
|
||||
|
||||
const [elements, setElements] = useState<"grade" | "lamp">(scoreBucket);
|
||||
|
||||
const base = useUGPTBase({ reqUser, game, playtype });
|
||||
|
||||
@@ -160,94 +164,3 @@ export function FolderInfoComponent({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+13
-6
@@ -10,6 +10,7 @@ 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 { useBucket } from "components/util/useBucket";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import React, { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Form, InputGroup } from "react-bootstrap";
|
||||
@@ -218,10 +219,9 @@ function TableBarChart({
|
||||
dataMap: Map<string, UGPTFolderStats>;
|
||||
}) {
|
||||
const gptConfig = GetGamePTConfig(table.game, table.playtype);
|
||||
const bucket = useBucket(table.game, table.playtype);
|
||||
|
||||
const [mode, setMode] = useState<"grades" | "lamps">(
|
||||
gptConfig.scoreBucket === "grade" ? "grades" : "lamps"
|
||||
);
|
||||
const [mode, setMode] = useState<"grades" | "lamps">(bucket === "grade" ? "grades" : "lamps");
|
||||
|
||||
const colours = useMemo(() => {
|
||||
if (mode === "grades") {
|
||||
@@ -254,7 +254,10 @@ function TableBarChart({
|
||||
}
|
||||
|
||||
arr.push({
|
||||
folder: data.folder.title,
|
||||
folder:
|
||||
data.folder.title.length > 10
|
||||
? `${data.folder.title.slice(0, 10)}...`
|
||||
: data.folder.title,
|
||||
...realData,
|
||||
});
|
||||
}
|
||||
@@ -327,7 +330,11 @@ function OverviewBarChart({
|
||||
}) {
|
||||
const longestTitle = useMemo(
|
||||
() =>
|
||||
dataset.map((e: any) => e.folder).sort(NumericSOV<string>(x => x.length, true))[0] ?? 0,
|
||||
Math.min(
|
||||
dataset.map((e: any) => e.folder).sort(NumericSOV<string>(x => x.length, true))[0]
|
||||
?.length ?? 0,
|
||||
10
|
||||
),
|
||||
[dataset]
|
||||
);
|
||||
|
||||
@@ -360,7 +367,7 @@ function OverviewBarChart({
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
valueFormat={v => `${v.toFixed(2)}%`}
|
||||
margin={{ left: 10 + longestTitle.length * 7, bottom: 50, top: 20, right: 20 }}
|
||||
margin={{ left: 10 + longestTitle * 8, bottom: 50, top: 20, right: 20 }}
|
||||
layout="horizontal"
|
||||
axisBottom={{
|
||||
tickSize: 5,
|
||||
|
||||
+3
-108
@@ -1,5 +1,6 @@
|
||||
import { ResponsiveBar } from "@nivo/bar";
|
||||
import { BarChartTooltip } from "components/charts/ChartTooltip";
|
||||
import FolderInfoHeader from "components/game/folder/FolderInfoHeader";
|
||||
import QuickTooltip from "components/layout/misc/QuickTooltip";
|
||||
import Card from "components/layout/page/Card";
|
||||
import FolderTable from "components/tables/folders/FolderTable";
|
||||
@@ -10,6 +11,7 @@ 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 { useBucket } from "components/util/useBucket";
|
||||
import { useFormik } from "formik";
|
||||
import { nanoid } from "nanoid";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
@@ -93,7 +95,7 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
|
||||
return (
|
||||
<FolderInfoHeader
|
||||
folderDataset={folderDataset}
|
||||
data={data}
|
||||
folderTitle={data.folder.title}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
@@ -111,9 +113,6 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
<Divider className="mb-4" />
|
||||
</div>
|
||||
<div className="col-12">{folderInfoHeader}</div>
|
||||
<div className="col-12">
|
||||
<Divider />
|
||||
@@ -658,107 +657,3 @@ function FolderDatasetToTierlistInfo(
|
||||
|
||||
return tierlistInfo.sort(NumericSOV(x => x.data.value, true));
|
||||
}
|
||||
|
||||
function FolderInfoHeader({ game, playtype, reqUser, folderDataset, data }: InfoProps) {
|
||||
return (
|
||||
<Card header={`${reqUser.username}'s ${data.folder.title} Breakdown`}>
|
||||
<ScoreDistributionChart
|
||||
game={game}
|
||||
data={data}
|
||||
folderDataset={folderDataset}
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreDistributionChart({ game, playtype, folderDataset }: InfoProps) {
|
||||
const dataMap = CreateChartIDMap(folderDataset);
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const dataset = [];
|
||||
|
||||
const expScale = GetGradeChartExpScale(game);
|
||||
|
||||
const expFn = ComposeExpFn(expScale);
|
||||
const invExpFn = ComposeInverseExpFn(expScale);
|
||||
|
||||
for (const data of folderDataset) {
|
||||
const value = data.__related.pb ? expFn(data.__related.pb.scoreData.percent) : 0;
|
||||
dataset.push({
|
||||
chartID: data.chartID,
|
||||
expValue: expFn(value),
|
||||
value,
|
||||
grade: data.__related.pb?.scoreData.grade,
|
||||
});
|
||||
}
|
||||
|
||||
dataset.sort(NumericSOV(x => x.expValue));
|
||||
|
||||
return (
|
||||
<div style={{ height: 400 }}>
|
||||
<ResponsiveBar
|
||||
indexBy="chartID"
|
||||
tooltip={d => (
|
||||
<BarChartTooltip
|
||||
point={d}
|
||||
renderFn={d => {
|
||||
const data = dataMap.get(d.indexValue as string)!;
|
||||
|
||||
return (
|
||||
<div className="w-100 text-center">
|
||||
{data.__related.song.title}
|
||||
<br />
|
||||
{data.__related.pb?.scoreData.percent.toFixed(2)}%
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
key={"value"}
|
||||
colors={k => {
|
||||
if (!k.data.grade) {
|
||||
return "black";
|
||||
}
|
||||
// @ts-expect-error temp
|
||||
return ChangeOpacity(gptConfig.gradeColours[k.data.grade], 0.5);
|
||||
}}
|
||||
// @ts-expect-error temp
|
||||
borderColor={k => gptConfig.gradeColours[k.data.grade]}
|
||||
borderWidth={1}
|
||||
padding={0.2}
|
||||
// @ts-expect-error temp
|
||||
data={dataset}
|
||||
minValue={0}
|
||||
maxValue={expFn(100)}
|
||||
margin={{ left: 50, top: 20, bottom: 20 }}
|
||||
valueFormat={e => `${invExpFn(e).toFixed(2)}%`}
|
||||
axisLeft={{
|
||||
tickValues: gptConfig.gradeBoundaries.map(e => (e === 0 ? 0 : expFn(e))),
|
||||
format: x => {
|
||||
let nearest;
|
||||
|
||||
const lgv = invExpFn(x);
|
||||
|
||||
for (const [i, gradeBnd] of gptConfig.gradeBoundaries.entries()) {
|
||||
if (Math.abs(gradeBnd - lgv) < 0.00005) {
|
||||
nearest = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nearest === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return gptConfig.grades[nearest];
|
||||
},
|
||||
}}
|
||||
axisBottom={null}
|
||||
{...DEFAULT_BAR_PROPS}
|
||||
labelSkipWidth={40}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,6 +164,7 @@ function SongChartRoutes({ game, playtype }: GamePT) {
|
||||
useEffect(() => {
|
||||
setActiveChart(null);
|
||||
}, [game, playtype]);
|
||||
|
||||
if (error) {
|
||||
return <ErrorPage statusCode={error.statusCode} customMessage={error.description} />;
|
||||
}
|
||||
@@ -172,6 +173,14 @@ function SongChartRoutes({ game, playtype }: GamePT) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (data.charts.every(c => c.playtype !== playtype)) {
|
||||
return (
|
||||
<Redirect
|
||||
to={`/dashboard/games/${game}/${data.charts[0].playtype}/songs/${data.song.id}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SongInfoHeader
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ResponsiveBar } from "@nivo/bar";
|
||||
import { BarChartTooltip } from "components/charts/ChartTooltip";
|
||||
import MiniTable from "components/tables/components/MiniTable";
|
||||
import Muted from "components/util/Muted";
|
||||
import React from "react";
|
||||
import { integer } from "tachi-common";
|
||||
import { ChangeOpacity } from "util/color-opacity";
|
||||
import { TACHI_CHART_THEME } from "util/constants/chart-theme";
|
||||
import { Reverse, StepFromToMax, PercentFrom } from "util/misc";
|
||||
|
||||
export default function FolderDistributionTable<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,74 @@
|
||||
import Card from "components/layout/page/Card";
|
||||
import Divider from "components/util/Divider";
|
||||
import Icon from "components/util/Icon";
|
||||
import SelectButton from "components/util/SelectButton";
|
||||
import { useBucket } from "components/util/useBucket";
|
||||
import React, { useState } from "react";
|
||||
import { PublicUserDocument } from "tachi-common";
|
||||
import { GamePT } from "types/react";
|
||||
import { FolderDataset } from "types/tables";
|
||||
import FolderMinimap from "./FolderMinimap";
|
||||
import FolderScoreDistributionChart from "./FolderScoreDistributionChart";
|
||||
|
||||
export default function FolderInfoHeader({
|
||||
game,
|
||||
playtype,
|
||||
reqUser,
|
||||
folderDataset,
|
||||
folderTitle,
|
||||
}: {
|
||||
reqUser: PublicUserDocument;
|
||||
folderDataset: FolderDataset;
|
||||
folderTitle: string;
|
||||
} & GamePT) {
|
||||
const scoreBucket = useBucket(game, playtype);
|
||||
const [view, setView] = useState<"grade" | "lamp">(scoreBucket);
|
||||
const [breakdownInfo, setBreakdownInfo] = useState<"graph" | "switchboard">("switchboard");
|
||||
|
||||
return (
|
||||
<Card header={`${reqUser.username}'s ${folderTitle} Breakdown`}>
|
||||
<div className="col-12 d-flex justify-content-center">
|
||||
<div className="btn-group">
|
||||
<SelectButton value={view} setValue={setView} id="grade">
|
||||
<Icon type="chart-area" />
|
||||
Score Info
|
||||
</SelectButton>
|
||||
<SelectButton value={view} setValue={setView} id="lamp">
|
||||
<Icon type="lightbulb" />
|
||||
Lamp Info
|
||||
</SelectButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 mt-4 d-flex justify-content-center">
|
||||
<select
|
||||
className="form-control"
|
||||
value={breakdownInfo}
|
||||
onChange={e => setBreakdownInfo(e.target.value as any)}
|
||||
>
|
||||
<option value="graph">Thermometer</option>
|
||||
<option value="switchboard">Switchboard</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
{breakdownInfo === "graph" ? (
|
||||
<FolderScoreDistributionChart
|
||||
game={game}
|
||||
folderDataset={folderDataset}
|
||||
playtype={playtype}
|
||||
view={view}
|
||||
/>
|
||||
) : (
|
||||
<FolderMinimap
|
||||
reqUser={reqUser}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
folderDataset={folderDataset}
|
||||
view={view}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import QuickTooltip from "components/layout/misc/QuickTooltip";
|
||||
import ApiError from "components/util/ApiError";
|
||||
import Divider from "components/util/Divider";
|
||||
import Loading from "components/util/Loading";
|
||||
import useApiQuery from "components/util/query/useApiQuery";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
ChartDocument,
|
||||
FormatChart,
|
||||
Game,
|
||||
GamePTConfig,
|
||||
GetGamePTConfig,
|
||||
IIDX_LAMPS,
|
||||
PublicUserDocument,
|
||||
SessionDocument,
|
||||
} from "tachi-common";
|
||||
import { GamePT } from "types/react";
|
||||
import { FolderDataset } from "types/tables";
|
||||
import { ChangeOpacity } from "util/color-opacity";
|
||||
import { CreateChartLink } from "util/data";
|
||||
import { NumericSOV } from "util/sorts";
|
||||
|
||||
type Props = {
|
||||
folderDataset: FolderDataset;
|
||||
reqUser: PublicUserDocument;
|
||||
view: "grade" | "lamp";
|
||||
} & GamePT;
|
||||
|
||||
export default function FolderMinimap(props: Props) {
|
||||
const { data, isLoading, error } = useApiQuery<SessionDocument>(
|
||||
`/users/${props.reqUser.id}/games/${props.game}/${props.playtype}/sessions/last`
|
||||
);
|
||||
|
||||
const session = useMemo(() => {
|
||||
if (error && error.statusCode === 404) {
|
||||
return null;
|
||||
} else if (data) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [data, error]);
|
||||
|
||||
if (error && error.statusCode !== 404) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-none d-lg-block">
|
||||
<FolderMinimapMain {...props} recentSession={session} />
|
||||
</div>
|
||||
<div className="d-block d-lg-none">
|
||||
Sadly, Switchboard view doesn't work very well on mobile at the moment.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderMinimapMain({
|
||||
game,
|
||||
playtype,
|
||||
folderDataset,
|
||||
view,
|
||||
recentSession,
|
||||
}: Props & { recentSession: SessionDocument | null }) {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const [sortAlg, setSortAlg] = useState(view === "grade" ? "score" : "lamp");
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "grade") {
|
||||
setSortAlg("score");
|
||||
} else {
|
||||
setSortAlg("lamp");
|
||||
}
|
||||
}, [view]);
|
||||
|
||||
const getterFn = useMemo<(f: FolderDataset[0]) => number | undefined>(() => {
|
||||
if (sortAlg === "score") {
|
||||
return c => c.__related.pb?.scoreData.percent;
|
||||
} else if (sortAlg === "lamp") {
|
||||
return c => c.__related.pb?.scoreData.lampIndex;
|
||||
} else if (sortAlg.startsWith("tierlist:")) {
|
||||
const v = sortAlg.split("tierlist:")[1];
|
||||
|
||||
return c => c.tierlistInfo[v as keyof ChartDocument["tierlistInfo"]]?.value;
|
||||
}
|
||||
|
||||
return () => 0;
|
||||
}, [sortAlg]);
|
||||
|
||||
const sortedDataset = useMemo(
|
||||
() => folderDataset.slice(0).sort(NumericSOV(a => getterFn(a) ?? -Infinity, true)),
|
||||
[getterFn, folderDataset, sortAlg]
|
||||
);
|
||||
|
||||
const recentlyTouched = useMemo(() => {
|
||||
if (!recentSession) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return recentSession.scoreInfo
|
||||
.filter(e => e.isNewScore || e.gradeDelta > 0 || e.lampDelta > 0)
|
||||
.map(e => e.scoreID);
|
||||
}, [recentSession]);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-12 col-lg-10 offset-lg-1">
|
||||
<div className="scoreinfo-grid-minimap">
|
||||
{sortedDataset.map(d => (
|
||||
<MinimapElement
|
||||
key={d.chartID}
|
||||
data={d}
|
||||
gptConfig={gptConfig}
|
||||
view={view}
|
||||
game={game}
|
||||
wasRecent={
|
||||
(d.__related.pb &&
|
||||
recentlyTouched.some(
|
||||
scoreID =>
|
||||
d.__related.pb!.composedFrom.scorePB === scoreID ||
|
||||
d.__related.pb!.composedFrom.lampPB === scoreID
|
||||
)) ??
|
||||
false
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Divider />
|
||||
<Form.Group>
|
||||
<Form.Label>Sorting charts on:</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
value={sortAlg}
|
||||
onChange={e => setSortAlg(e.target.value)}
|
||||
>
|
||||
{view === "grade" && <option value="score">Your Score</option>}
|
||||
{view === "lamp" && <option value="lamp">Your Lamp</option>}
|
||||
{gptConfig.tierlists.map(e => (
|
||||
<option key={`tierlist:${e}`} value={`tierlist:${e}`}>
|
||||
Tierlist: {e}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MinimapElement({
|
||||
data,
|
||||
gptConfig,
|
||||
view,
|
||||
game,
|
||||
wasRecent,
|
||||
}: {
|
||||
data: FolderDataset[0];
|
||||
view: "grade" | "lamp";
|
||||
gptConfig: GamePTConfig;
|
||||
game: Game;
|
||||
wasRecent: boolean;
|
||||
}) {
|
||||
let icon = "level-up-alt";
|
||||
|
||||
// if this user recently cleared mare nectaris
|
||||
// tell them to fuck off
|
||||
if (
|
||||
data.chartID === "924bf011fdd8334b609b02e382123f9f5440d16d" &&
|
||||
data.__related.pb &&
|
||||
data.__related.pb?.scoreData.lampIndex >= IIDX_LAMPS.EASY_CLEAR
|
||||
) {
|
||||
icon = "hand-middle-finger";
|
||||
}
|
||||
|
||||
const colour = useMemo(() => {
|
||||
if (!data.__related.pb) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// @ts-expect-error unhinged dynamic access (i dont care)
|
||||
return gptConfig[`${view}Colours`][data.__related.pb.scoreData[view]];
|
||||
}, [data.__related.pb, gptConfig, view]);
|
||||
|
||||
return (
|
||||
<QuickTooltip
|
||||
tooltipContent={
|
||||
<div>
|
||||
<span>{FormatChart(game, data.__related.song, data)}</span>
|
||||
<Divider />
|
||||
{wasRecent && (
|
||||
<>
|
||||
<b>You raised this recently!</b>
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
<span>{data.__related.pb?.scoreData.lamp ?? "Not Played"}</span>
|
||||
{data.__related.pb && (
|
||||
<>
|
||||
<br />
|
||||
{data.__related.pb.scoreData.grade}
|
||||
<br />
|
||||
<span>
|
||||
{data.__related.pb.scoreData.score} (
|
||||
{data.__related.pb.scoreData.percent.toFixed(2)}%)
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Link to={CreateChartLink(data, game)}>
|
||||
<div
|
||||
className={`scoreinfo-grid-minimap-element ${
|
||||
wasRecent ? "scoreinfo-grid-minimap-element-recent" : ""
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: colour ? ChangeOpacity(colour, 0.3) : undefined,
|
||||
}}
|
||||
>
|
||||
{wasRecent && (
|
||||
<span className={`fas fa-${icon}`} style={{ lineHeight: "19.5px" }}></span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</QuickTooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { ResponsiveBar } from "@nivo/bar";
|
||||
import { BarChartTooltip } from "components/charts/ChartTooltip";
|
||||
import React, { useMemo } from "react";
|
||||
import { GetGamePTConfig } from "tachi-common";
|
||||
import { GamePT } from "types/react";
|
||||
import { FolderDataset } from "types/tables";
|
||||
import { DEFAULT_BAR_PROPS } from "util/charts";
|
||||
import { ChangeOpacity } from "util/color-opacity";
|
||||
import { CreateChartIDMap } from "util/data";
|
||||
import { ComposeExpFn, ComposeInverseExpFn, CountElements, Reverse } from "util/misc";
|
||||
import { GetGradeChartExpScale } from "util/scales";
|
||||
import { NumericSOV } from "util/sorts";
|
||||
import FolderDistributionTable from "./FolderDistributionTable";
|
||||
|
||||
type Props = {
|
||||
folderDataset: FolderDataset;
|
||||
view: "grade" | "lamp";
|
||||
} & GamePT;
|
||||
|
||||
export default function FolderScoreDistributionChart({
|
||||
game,
|
||||
playtype,
|
||||
folderDataset,
|
||||
view,
|
||||
}: Props) {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const values = useMemo(() => {
|
||||
if (view === "grade") {
|
||||
return CountElements(folderDataset, x => x.__related.pb?.scoreData.grade ?? null);
|
||||
}
|
||||
|
||||
return CountElements(folderDataset, x => x.__related.pb?.scoreData.lamp ?? null);
|
||||
}, [folderDataset, view]);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-12 col-lg-6 offset-lg-3">
|
||||
{view === "grade" ? (
|
||||
<FolderDistributionTable
|
||||
colours={gptConfig.gradeColours}
|
||||
keys={Reverse(gptConfig.grades)}
|
||||
max={folderDataset.length}
|
||||
values={values}
|
||||
/>
|
||||
) : (
|
||||
<FolderDistributionTable
|
||||
colours={gptConfig.lampColours}
|
||||
keys={Reverse(gptConfig.lamps)}
|
||||
max={folderDataset.length}
|
||||
values={values}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* <div className="col-12 col-lg-8">
|
||||
<ScoreDistributionChart {...{ game, playtype, folderDataset, view }} />
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreDistributionChart({ game, folderDataset, playtype, view }: Props) {
|
||||
if (view === "grade") {
|
||||
return <ScoreBarChart game={game} playtype={playtype} folderDataset={folderDataset} />;
|
||||
}
|
||||
|
||||
return <>LAMP TODO</>;
|
||||
}
|
||||
|
||||
function ScoreBarChart({ game, playtype, folderDataset }: Omit<Props, "view">) {
|
||||
const dataMap = CreateChartIDMap(folderDataset);
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const expScale = GetGradeChartExpScale(game);
|
||||
const expFn = ComposeExpFn(expScale);
|
||||
const invExpFn = ComposeInverseExpFn(expScale);
|
||||
|
||||
const dataset = [];
|
||||
|
||||
for (const data of folderDataset) {
|
||||
const value = data.__related.pb ? expFn(data.__related.pb.scoreData.percent) : 0;
|
||||
dataset.push({
|
||||
chartID: data.chartID,
|
||||
expValue: expFn(value),
|
||||
value,
|
||||
grade: data.__related.pb?.scoreData.grade,
|
||||
});
|
||||
}
|
||||
|
||||
dataset.sort(NumericSOV(x => x.expValue));
|
||||
|
||||
return (
|
||||
<div style={{ height: 400 }}>
|
||||
<ResponsiveBar
|
||||
indexBy="chartID"
|
||||
tooltip={d => (
|
||||
<BarChartTooltip
|
||||
point={d}
|
||||
renderFn={d => {
|
||||
const data = dataMap.get(d.indexValue as string)!;
|
||||
|
||||
return (
|
||||
<div className="w-100 text-center">
|
||||
{data.__related.song.title}
|
||||
<br />
|
||||
{data.__related.pb?.scoreData.percent.toFixed(2)}%
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
key={"value"}
|
||||
colors={k => {
|
||||
if (!k.data.grade) {
|
||||
return "black";
|
||||
}
|
||||
// @ts-expect-error temp
|
||||
return ChangeOpacity(gptConfig.gradeColours[k.data.grade], 0.5);
|
||||
}}
|
||||
// @ts-expect-error temp
|
||||
borderColor={k => gptConfig.gradeColours[k.data.grade]}
|
||||
borderWidth={1}
|
||||
padding={0.2}
|
||||
// @ts-expect-error temp
|
||||
data={dataset}
|
||||
minValue={0}
|
||||
maxValue={expFn(100)}
|
||||
margin={{ left: 50, top: 20, bottom: 20 }}
|
||||
valueFormat={e => `${invExpFn(e).toFixed(2)}%`}
|
||||
axisLeft={{
|
||||
tickValues: gptConfig.gradeBoundaries.map(e => (e === 0 ? 0 : expFn(e))),
|
||||
format: x => {
|
||||
let nearest;
|
||||
|
||||
const lgv = invExpFn(x);
|
||||
|
||||
for (const [i, gradeBnd] of gptConfig.gradeBoundaries.entries()) {
|
||||
if (Math.abs(gradeBnd - lgv) < 0.00005) {
|
||||
nearest = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nearest === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return gptConfig.grades[nearest];
|
||||
},
|
||||
}}
|
||||
axisBottom={null}
|
||||
{...DEFAULT_BAR_PROPS}
|
||||
labelSkipWidth={40}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import useApiQuery from "components/util/query/useApiQuery";
|
||||
import HasDevModeOn from "components/util/HasDevModeOn";
|
||||
import PBCompare from "./components/PBCompare";
|
||||
import { UserSettingsContext } from "context/UserSettingsContext";
|
||||
import ManageScore from "./components/ManageScore";
|
||||
|
||||
export interface ScoreState {
|
||||
highlight: boolean;
|
||||
@@ -46,7 +47,7 @@ export default function GenericScoreDropdown<I extends IDStrings = IDStrings>({
|
||||
user: PublicUserDocument;
|
||||
chart: ChartDocument;
|
||||
scoreState: ScoreState;
|
||||
defaultView?: "vsPB" | "moreInfo" | "history" | "debug";
|
||||
defaultView?: "vsPB" | "moreInfo" | "history" | "debug" | "manage";
|
||||
thisScore: ScoreDocument;
|
||||
DocComponent?: (props: {
|
||||
score: ScoreDocument<I> | PBScoreDocument<I>;
|
||||
@@ -55,6 +56,8 @@ export default function GenericScoreDropdown<I extends IDStrings = IDStrings>({
|
||||
}) => JSX.Element;
|
||||
} & GamePT) {
|
||||
const [view, setView] = useState(defaultView);
|
||||
const { user: currentUser } = useContext(UserContext);
|
||||
const { settings } = useContext(UserSettingsContext);
|
||||
|
||||
const { isLoading, error, data } = useApiQuery<UGPTChartPBComposition<I>>(
|
||||
`/users/${user.id}/games/${game}/${playtype}/pbs/${chart.chartID}?getComposition=true`
|
||||
@@ -94,6 +97,8 @@ export default function GenericScoreDropdown<I extends IDStrings = IDStrings>({
|
||||
body = <DocComponent score={thisScore as any} scoreState={scoreState} pbData={data} />;
|
||||
} else if (view === "vsPB") {
|
||||
body = <PBCompare data={data} DocComponent={DocComponent} scoreState={scoreState} />;
|
||||
} else if (view === "manage") {
|
||||
body = <ManageScore score={thisScore} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -112,6 +117,12 @@ export default function GenericScoreDropdown<I extends IDStrings = IDStrings>({
|
||||
<Icon type="history" />
|
||||
Play History
|
||||
</SelectButton>
|
||||
{currentUser?.id === user.id && settings?.preferences.deletableScores && (
|
||||
<SelectButton setValue={setView} value={view} id="manage">
|
||||
<Icon type="trash" />
|
||||
Manage Score
|
||||
</SelectButton>
|
||||
)}
|
||||
<HasDevModeOn>
|
||||
<SelectButton setValue={setView} value={view} id="debug">
|
||||
<Icon type="bug" />
|
||||
|
||||
@@ -1,38 +1,33 @@
|
||||
import DeltaCell from "components/tables/cells/DeltaCell";
|
||||
import LampCell from "components/tables/cells/LampCell";
|
||||
import ScoreCell from "components/tables/cells/ScoreCell";
|
||||
import TimestampCell from "components/tables/cells/TimestampCell";
|
||||
import ExternalLink from "components/util/ExternalLink";
|
||||
import ScoreCoreCells from "components/tables/game-core-cells/ScoreCoreCells";
|
||||
import useScoreRatingAlg from "components/util/useScoreRatingAlg";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { PBScoreDocument, ScoreDocument } from "tachi-common";
|
||||
import { UGPTChartPBComposition } from "types/api-returns";
|
||||
import { SetState } from "types/react";
|
||||
import { IsScore } from "util/asserts";
|
||||
import { UppercaseFirst } from "util/misc";
|
||||
import CommentContainer from "./CommentContainer";
|
||||
import JudgementTable from "./JudgementTable";
|
||||
import PBNote from "./PBNote";
|
||||
import ScoreEditButtons from "./ScoreEditButtons";
|
||||
|
||||
export function ScoreInfo({ score }: { score: ScoreDocument | PBScoreDocument }) {
|
||||
const rating = useScoreRatingAlg(score.game, score.playtype);
|
||||
|
||||
return (
|
||||
<div className="col-12">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td colSpan={100}>Score Info</td>
|
||||
<td colSpan={3}>Score Info</td>
|
||||
<td>{UppercaseFirst(rating)}</td>
|
||||
<td>Timestamp</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<ScoreCell score={score} />
|
||||
<DeltaCell
|
||||
game={score.game}
|
||||
playtype={score.playtype}
|
||||
score={score.scoreData.score}
|
||||
percent={score.scoreData.percent}
|
||||
grade={score.scoreData.grade}
|
||||
/>
|
||||
<LampCell score={score} />
|
||||
<ScoreCoreCells game={score.game} score={score} rating={rating} />
|
||||
{/* @ts-expect-error yeah we know service doesnt necessarily exist */}
|
||||
<TimestampCell time={score.timeAchieved} service={score?.service} />
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { useMemo, useReducer, useState } from "react";
|
||||
import { ScoreDocument } from "tachi-common";
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { DelayedPageReload } from "util/misc";
|
||||
|
||||
export default function ManageScore({ score }: { score: ScoreDocument }) {
|
||||
const [warn, upgWarn] = useReducer(r => r + 1, 0);
|
||||
const message = useMemo(() => {
|
||||
if (warn === 0) {
|
||||
return "Delete Score (Requires Further Confirmation)";
|
||||
} else if (warn === 1) {
|
||||
return "Are you absolutely sure? This score will be gone. Permanently.";
|
||||
} else if (warn === 2) {
|
||||
return "I'm serious. You will lose this score. It will be gone. Are you REALLY sure you want to do this?";
|
||||
} else if (warn === 3) {
|
||||
return "OK. Click me one last time, then.";
|
||||
}
|
||||
|
||||
return "lol unknown state";
|
||||
}, [warn]);
|
||||
|
||||
return (
|
||||
<div className="d-flex w-100 justify-content-center">
|
||||
<div
|
||||
className="btn btn-danger"
|
||||
onClick={() => {
|
||||
if (warn < 3) {
|
||||
upgWarn();
|
||||
} else {
|
||||
APIFetchV1(
|
||||
`/scores/${score.scoreID}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
true,
|
||||
true
|
||||
).then(() => DelayedPageReload());
|
||||
}
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export default function ScoreEditButtons({
|
||||
onClick={() => setShow(true)}
|
||||
>
|
||||
<Icon type="file-signature" />
|
||||
Edit
|
||||
Comment
|
||||
</Button>
|
||||
</QuickTooltip>
|
||||
</>
|
||||
@@ -49,7 +49,7 @@ export default function ScoreEditButtons({
|
||||
<QuickTooltip tooltipContent="Comment on this score.">
|
||||
<Button variant="outline-secondary" onClick={() => setShow(true)}>
|
||||
<Icon type="file-signature" />
|
||||
Edit
|
||||
Comment
|
||||
</Button>
|
||||
</QuickTooltip>
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { UGPTPreferenceStatsReturn } from "types/api-returns";
|
||||
import { GamePT } from "types/react";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { UppercaseFirst } from "util/misc";
|
||||
import { ToPercent, UppercaseFirst } from "util/misc";
|
||||
import UGPTStatContainer from "./UGPTStatContainer";
|
||||
import UGPTStatCreator from "./UGPTStatCreator";
|
||||
|
||||
@@ -424,15 +424,22 @@ export function StatDisplay({
|
||||
header={<h5 className="text-muted mb-0">Folder</h5>}
|
||||
>
|
||||
<>
|
||||
<h4>{headerStr}</h4>
|
||||
<Link
|
||||
className="gentle-link"
|
||||
to={`/dashboard/users/${reqUser.id}/games/${game}/${playtype}/folders/${folders[0].folderID}`}
|
||||
>
|
||||
<h4>{headerStr}</h4>
|
||||
</Link>
|
||||
<h5>
|
||||
{UppercaseFirst(stat.property)} >={" "}
|
||||
{FormatPropertyGTE(game, playtype, stat.property, stat.gte)}
|
||||
</h5>
|
||||
<h4>
|
||||
{result.value}
|
||||
{/* @ts-expect-error temp */}
|
||||
<small className="text-muted">/{result.outOf}</small>
|
||||
<small className="text-muted">
|
||||
{/* @ts-expect-error This property definitely exists.*/}
|
||||
{/* */}/{result.outOf} ({ToPercent(result.value, result.outOf)})
|
||||
</small>
|
||||
</h4>
|
||||
|
||||
{user && user.id !== reqUser.id && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import CenterLayoutPage from "../layout/CenterLayoutPage";
|
||||
import DebugContent from "./DebugContent";
|
||||
import Divider from "./Divider";
|
||||
import ExternalLink from "./ExternalLink";
|
||||
import Muted from "./Muted";
|
||||
|
||||
export default class ErrorBoundary extends React.Component<any, { error: any; info: any }> {
|
||||
constructor(props: any) {
|
||||
@@ -32,6 +33,11 @@ export default class ErrorBoundary extends React.Component<any, { error: any; in
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
<Muted>This error is not recoverable. :/</Muted>
|
||||
<Divider />
|
||||
<a href="#" onClick={() => window.location.reload()}>
|
||||
Attempt Page Reload
|
||||
</a>
|
||||
<ExternalLink href="/">Go Home</ExternalLink>
|
||||
</CenterLayoutPage>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { UGPTSettingsContext } from "context/UGPTSettingsContext";
|
||||
import { useContext } from "react";
|
||||
import { Game, GetGamePTConfig } from "tachi-common";
|
||||
import { Playtype } from "types/tachi";
|
||||
|
||||
export function useBucket(game: Game, playtype: Playtype) {
|
||||
const { settings } = useContext(UGPTSettingsContext);
|
||||
|
||||
if (!settings?.preferences.scoreBucket) {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
return gptConfig.scoreBucket;
|
||||
}
|
||||
|
||||
return settings.preferences.scoreBucket;
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
* @param opacity A value between 0 and 1.
|
||||
*/
|
||||
export function ChangeOpacity(str: string, opacity: number) {
|
||||
if (!str) {
|
||||
throw new Error(`Str given to ChangeOpacity was undefined.`);
|
||||
}
|
||||
|
||||
if (str.startsWith("#")) {
|
||||
if (str.length === 7) {
|
||||
return str + Math.floor(opacity * 255).toString(16);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const MAJOR = 2;
|
||||
const MINOR = 0;
|
||||
const PATCH = 1;
|
||||
const PATCH = 2;
|
||||
|
||||
// As is with all front-facing zkldi projects, the version names for tachi-client
|
||||
// are from an album I like. In this case, the album is The Cure - Disintegration.
|
||||
|
||||
@@ -197,3 +197,26 @@ export function HistorySafeGoBack(history: ReturnType<typeof useHistory>) {
|
||||
// history.goBack();
|
||||
// }
|
||||
}
|
||||
export function ToPercent(n1: number, n2: number) {
|
||||
return `${((100 * n1) / n2).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export function CountElements<T>(data: T[], collector: (element: T) => string | null) {
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const element of data) {
|
||||
const key = collector(element);
|
||||
|
||||
if (key === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!counts[key]) {
|
||||
counts[key] = 1;
|
||||
} else {
|
||||
counts[key]++;
|
||||
}
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
@@ -112,6 +112,8 @@ export const contentiousSplashes = [
|
||||
"...and good faith is when people agree with me",
|
||||
"Anyone else at this party gay?",
|
||||
"I have the transfeminine urge to pee.",
|
||||
"I HAVE TO LIE IN THE MIDDLE OF THE FLOOR",
|
||||
"COMPLETELY MOTIONLESS NOT DARING TO BREATHE",
|
||||
];
|
||||
|
||||
export const heySplashes = [
|
||||
|
||||
Reference in New Issue
Block a user