diff --git a/.env.template b/.env.template
new file mode 100644
index 000000000..ede356aab
--- /dev/null
+++ b/.env.template
@@ -0,0 +1,9 @@
+# Generate one of your own for free at
+# https://start.gg/admin/profile/developer
+# Read more about start.gg auth here:
+# https://developer.start.gg/docs/authentication
+STARTGG_TOKEN=YOUR_TOKEN_HERE
+
+# For persistence of the partykit state to somewhere more reliable
+SUPABASE_URL=SOMETHING
+SUPABASE_KEY=SOMETHING
diff --git a/codegen.ts b/codegen.ts
new file mode 100644
index 000000000..3f5005a14
--- /dev/null
+++ b/codegen.ts
@@ -0,0 +1,17 @@
+import { CodegenConfig } from "@graphql-codegen/cli";
+import baseConfig from "./graphql.config";
+
+const config: CodegenConfig = {
+ ...baseConfig,
+ ignoreNoDocuments: false, // for better experience with the watcher
+ generates: {
+ "./src/startgg-gql/generated/": {
+ preset: "client",
+ presetConfig: {
+ fragmentMasking: false,
+ },
+ },
+ },
+};
+
+export default config;
diff --git a/graphql.config.ts b/graphql.config.ts
new file mode 100644
index 000000000..ff650c77b
--- /dev/null
+++ b/graphql.config.ts
@@ -0,0 +1,14 @@
+import "dotenv/config";
+
+export default {
+ schema: [
+ {
+ "https://api.start.gg/gql/alpha": {
+ headers: {
+ Authorization: `Bearer ${process.env.STARTGG_TOKEN}`,
+ },
+ },
+ },
+ ],
+ documents: ["./src/startgg-gql/*.ts"],
+};
diff --git a/package.json b/package.json
index 7badddf60..0db0f4680 100644
--- a/package.json
+++ b/package.json
@@ -46,9 +46,14 @@
"@blueprintjs/datetime2": "2.3.15",
"@blueprintjs/icons": "5.14.0",
"@blueprintjs/select": "5.3.3",
+ "@blueprintjs/table": "^5.2.2",
"@eslint/js": "^9.14.0",
+ "@graphql-codegen/cli": "5.0.3",
+ "@graphql-codegen/client-preset": "4.4.0",
"@lcdp/offline-plugin": "5.1.1",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.15",
+ "@reduxjs/toolkit": "2.3.0",
+ "@supabase/supabase-js": "^2.45.4",
"@types/better-sqlite3": "7.6.11",
"@types/eslint__js": "^8.42.3",
"@types/fuzzy-search": "2.1.5",
@@ -58,6 +63,8 @@
"@types/papaparse": "patch:@types/papaparse@npm%3A5.3.14#~/.yarn/patches/@types-papaparse-npm-5.3.14-864eddd3a0.patch",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
+ "@urql/core": "^5.0.4",
+ "@urql/exchange-graphcache": "^7.1.2",
"autoprefixer": "10.4.20",
"axios": "1.7.7",
"babel-loader": "9.2.1",
@@ -71,6 +78,7 @@
"css-loader": "7.1.2",
"css-minimizer-webpack-plugin": "7.0.0",
"date-fns": "2.30.0",
+ "dotenv": "^16.4.5",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react-hooks": "^5.0.0",
@@ -78,6 +86,7 @@
"favicons-webpack-plugin": "6.0.1",
"fork-ts-checker-webpack-plugin": "9.0.2",
"fuzzy-search": "3.2.1",
+ "graphql": "^16.9.0",
"he": "1.2.0",
"html-entities": "2.5.2",
"html-loader": "5.1.0",
@@ -95,6 +104,8 @@
"normalize.css": "8.0.1",
"p-queue": "8.0.1",
"papaparse": "5.4.1",
+ "partykit": "0.0.111",
+ "partysocket": "1.0.1",
"peerjs": "2.0.0-beta.3",
"postcss": "8.4.49",
"postcss-loader": "8.1.1",
@@ -103,13 +114,16 @@
"react-dom": "18.3.1",
"react-error-boundary": "4.1.2",
"react-intl": "6.8.7",
+ "react-redux": "^9.1.2",
"react-refresh": "0.14.2",
+ "react-router-dom": "^6.24.1",
"sanitize-filename": "1.6.3",
"simfile-parser": "0.7.2",
"style-loader": "4.0.0",
"typescript": "5.6.3",
"typescript-eslint": "^8.14.0",
"undici": "6.21.0",
+ "urql": "^4.1.0",
"victory": "37.3.2",
"webpack": "5.96.1",
"webpack-cli": "5.1.4",
diff --git a/partykit.json b/partykit.json
new file mode 100644
index 000000000..ff146517e
--- /dev/null
+++ b/partykit.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://www.partykit.io/schema.json",
+ "name": "ddr-card-draw-party",
+ "main": "src/party/server.ts",
+ "compatibilityDate": "2024-06-21"
+}
diff --git a/src/app.tsx b/src/app.tsx
index 09bf92c43..b3a1e3297 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -1,5 +1,6 @@
import "normalize.css";
import "@blueprintjs/core/lib/css/blueprint.css";
+import "@blueprintjs/table/lib/css/table.css";
import "@blueprintjs/icons/lib/css/blueprint-icons.css";
import "@blueprintjs/select/lib/css/blueprint-select.css";
import "@blueprintjs/datetime2/lib/css/blueprint-datetime2.css";
@@ -8,21 +9,145 @@ import { FocusStyleManager } from "@blueprintjs/core";
FocusStyleManager.onlyShowFocusOnTabs();
-import { DrawingList } from "./drawing-list";
import { UpdateManager } from "./update-manager";
-import { DrawStateManager } from "./draw-state";
+import { IntlProvider } from "./intl-provider";
import { Header } from "./header";
import { ThemeSyncWidget } from "./theme-toggle";
-import { DropHandler } from "./drop-handler";
+import { Provider } from "react-redux";
+import { store } from "./state/store";
+import { PartySocketManager } from "./party/client";
+import { Provider as UrqlProvider } from "urql";
+
+import {
+ createBrowserRouter,
+ Outlet,
+ RouterProvider,
+ useParams,
+ Link,
+} from "react-router-dom";
+import { CabManagement } from "./cab-management";
+import { MainView } from "./main-view";
+import { nanoid } from "nanoid";
+import { urqlClient } from "./startgg-gql";
+
+const router = createBrowserRouter([
+ {
+ path: "/",
+ Component: () => {
+ return (
+
+
DDR Tools Event Mode
+
Alpha Preview
+
+ You need to pick an event first. Would you like to:{" "}
+ Create New Event?
+
+
+ No idea what this is?{" "}
+ Here's a video trying to
+ explain how to use it!
+
+
+ );
+ },
+ },
+ {
+ path: "e/:roomName",
+ element: ,
+ },
+ {
+ path: "e/:roomName/cab/:cabId/source",
+ element: ,
+ children: [
+ {
+ path: "cards",
+ lazy: async () => {
+ const { CabCards } = await import("./obs-sources/cards");
+ return { Component: CabCards };
+ },
+ },
+ {
+ path: "title",
+ lazy: async () => {
+ const { CabTitle } = await import("./obs-sources/text");
+ return { Component: CabTitle };
+ },
+ },
+ {
+ path: "players",
+ lazy: async () => {
+ const { CabPlayers } = await import("./obs-sources/text");
+ return { Component: CabPlayers };
+ },
+ },
+ {
+ path: "p1",
+ lazy: async () => {
+ const { CabPlayer } = await import("./obs-sources/text");
+ return { element: };
+ },
+ },
+ {
+ path: "p2",
+ lazy: async () => {
+ const { CabPlayer } = await import("./obs-sources/text");
+ return { element: };
+ },
+ },
+ ],
+ },
+]);
+
+function ObsSource() {
+ const params = useParams<"roomName" | "cabId">();
+ if (!params.roomName) {
+ return null;
+ }
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+function AppForRoom() {
+ const params = useParams<"roomName">();
+ if (!params.roomName) {
+ return null;
+ }
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
export function App() {
return (
-
+
-
-
-
-
+
+
);
}
diff --git a/src/apply-default-config.tsx b/src/apply-default-config.tsx
deleted file mode 100644
index 3dfdf07ef..000000000
--- a/src/apply-default-config.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { GameData } from "./models/SongData";
-import { useEffect } from "react";
-import { ConfigState, useConfigState } from "./config-state";
-
-interface Props {
- defaults?: GameData["defaults"];
- granularResolution: number | undefined;
-}
-
-export function ApplyDefaultConfig({ defaults, granularResolution }: Props) {
- useEffect(() => {
- if (!defaults) {
- return;
- }
-
- useConfigState.setState(() => {
- const {
- lowerLvlBound,
- upperLvlBound,
- flags,
- difficulties,
- folders,
- style,
- } = defaults;
- const ret: Partial = {
- lowerBound: lowerLvlBound,
- upperBound: upperLvlBound,
- flags: new Set(flags),
- difficulties: new Set(difficulties),
- folders: new Set(folders),
- style,
- cutoffDate: "",
- };
- if (!granularResolution) {
- ret.useGranularLevels = false;
- }
- return ret;
- });
- }, [defaults, granularResolution]);
- return null;
-}
diff --git a/src/cab-management.tsx b/src/cab-management.tsx
new file mode 100644
index 000000000..cb9dd22a2
--- /dev/null
+++ b/src/cab-management.tsx
@@ -0,0 +1,238 @@
+import {
+ Button,
+ Card,
+ ControlGroup,
+ InputGroup,
+ Menu,
+ MenuItem,
+ Popover,
+ Tooltip,
+} from "@blueprintjs/core";
+import { useAppDispatch, useAppState } from "./state/store";
+import React, { ReactNode, useCallback, useState } from "react";
+import { CabInfo, eventSlice } from "./state/event.slice";
+import {
+ Add,
+ CaretLeft,
+ CaretRight,
+ Cross,
+ Font,
+ Layers,
+ MobileVideo,
+ More,
+ People,
+ Person,
+ Remove,
+} from "@blueprintjs/icons";
+import { detectedLanguage } from "./utils";
+import { copyPlainTextToClipboard } from "./utils/share";
+import { useSetAtom } from "jotai";
+import { mainTabAtom } from "./main-view";
+import { playerNameByIndex } from "./models/Drawing";
+
+export function CabManagement() {
+ const [isCollapsed, setCollapsed] = useState(true);
+ const cabs = useAppState(eventSlice.selectors.allCabs);
+
+ if (isCollapsed) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {cabs.map((cab) => (
+
+ ))}
+
+
+ );
+}
+
+function AddCabControl(props: { children?: ReactNode }) {
+ const [name, setName] = useState("");
+ const dispatch = useAppDispatch();
+ const addCab = useCallback(() => {
+ dispatch(eventSlice.actions.addCab(name));
+ setName("");
+ }, [dispatch, name]);
+ return (
+
+ );
+}
+
+function CabSummary({ cab }: { cab: CabInfo }) {
+ const dispatch = useAppDispatch();
+ const removeCab = useCallback(
+ () => dispatch(eventSlice.actions.removeCab(cab.id)),
+ [dispatch, cab.id],
+ );
+
+ const copySource = useCallback(
+ (e: React.MouseEvent) => {
+ const sourceType = e.currentTarget.dataset.source;
+ if (sourceType) {
+ const sourcePath = `${window.location.pathname}/cab/${cab.id}/source/${sourceType}`;
+ const sourceUrl = new URL(sourcePath, window.location.href);
+ console.info("Coyping source URL", sourceUrl.href);
+ copyPlainTextToClipboard(
+ sourceUrl.href,
+ "Copied OBS source URL to clipboard",
+ );
+ }
+ },
+ [cab],
+ );
+
+ const sourcesMenu = (
+
+ );
+
+ return (
+
+
+ {cab.name}{" "}
+
+ } />
+ {" "}
+
+
+
+ );
+}
+
+const listFormatter = new Intl.ListFormat(detectedLanguage, {
+ style: "short",
+ type: "unit",
+});
+
+function CurrentMatch(props: { cab: CabInfo }) {
+ const dispatch = useAppDispatch();
+ const removeCab = useCallback(
+ () =>
+ dispatch(
+ eventSlice.actions.assignMatchToCab({
+ cabId: props.cab.id,
+ matchId: null,
+ }),
+ ),
+ [dispatch, props.cab.id],
+ );
+ const drawing = useAppState((s) => {
+ if (!props.cab.activeMatch) return null;
+ return s.drawings.entities[props.cab.activeMatch] || null;
+ });
+ const setMainTab = useSetAtom(mainTabAtom);
+
+ const scrollToDrawing = useCallback(() => {
+ if (!drawing) {
+ return;
+ }
+ const el = document.getElementById(`drawing:${drawing.id}`);
+ if (!el) {
+ return;
+ }
+ const priorFocus = document.querySelector(
+ "[data-focused]",
+ ) as HTMLElement | null;
+ if (priorFocus) {
+ delete priorFocus.dataset.focused;
+ }
+ setMainTab("drawings");
+ el.scrollIntoView({ behavior: "smooth" });
+ el.dataset.focused = "";
+ }, [drawing, setMainTab]);
+
+ if (!drawing) {
+ return No match
;
+ }
+ const filledPlayers = drawing.playerDisplayOrder.map((pIdx, idx) =>
+ playerNameByIndex(drawing.meta, pIdx, `Player ${idx + 1}`),
+ );
+ return (
+
+ }
+ style={{ position: "absolute", right: "0.5em", top: "0.5em" }}
+ onClick={removeCab}
+ />
+ {drawing.meta.title}
+ {listFormatter.format(filledPlayers)}
+
+ );
+}
diff --git a/src/card-draw.ts b/src/card-draw.ts
index 656a5e733..3de5b2bf4 100644
--- a/src/card-draw.ts
+++ b/src/card-draw.ts
@@ -70,8 +70,10 @@ export function songIsValid(
return false;
}
return (
- (!song.folder || !config.folders.size || config.folders.has(song.folder)) &&
- (!song.flags || song.flags.every((f) => config.flags.has(f)))
+ (!song.folder ||
+ !config.folders.length ||
+ config.folders.includes(song.folder)) &&
+ (!song.flags || song.flags.every((f) => config.flags.includes(f)))
);
}
@@ -90,10 +92,10 @@ export function chartIsValid(
const levelMetric = chartLevelOrTier(chart, config.useGranularLevels);
return (
chart.style === config.style &&
- config.difficulties.has(chart.diffClass) &&
+ config.difficulties.includes(chart.diffClass) &&
levelMetric >= config.lowerBound &&
levelMetric <= config.upperBound &&
- (!chart.flags || chart.flags.every((f) => config.flags.has(f)))
+ (!chart.flags || chart.flags.every((f) => config.flags.includes(f)))
);
}
@@ -223,19 +225,27 @@ function bucketIndexForLvl(lvl: number, buckets: LvlRanges): number | null {
return null;
}
+export type StartggInfo = Pick;
+export type StartingPoint = Drawing | StartggInfo;
+
+const artistDrawBlocklist = new Set();
+
/**
* Produces a drawn set of charts given the song data and the user
* input of the html form elements.
* @param songs The song data (see `src/songs/`)
* @param configData the data gathered by all form elements on the page, indexed by `name` attribute
*/
-export function draw(gameData: GameData, configData: ConfigState): Drawing {
+export function draw(
+ gameData: GameData,
+ configData: ConfigState,
+ startPoint: StartingPoint,
+): Drawing {
const {
chartCount: numChartsToRandom,
useWeights,
forceDistribution,
weights,
- defaultPlayersPerDraw,
useGranularLevels,
} = configData;
@@ -247,10 +257,15 @@ export function draw(gameData: GameData, configData: ConfigState): Drawing {
getBuckets(configData, availableLvls, gameData.meta.granularTierResolution),
);
- for (const chart of eligibleCharts(configData, gameData)) {
- const bucketIdx = useWeights
+ function bucketIndexForChart(chart: EligibleChart) {
+ return useWeights
? bucketIndexForLvl(chartLevelOrTier(chart, useGranularLevels), buckets)
: 0; // outside of weights mode we just put all songs into one shared bucket
+ }
+
+ for (const chart of eligibleCharts(configData, gameData)) {
+ if (artistDrawBlocklist.has(chart.artist)) continue;
+ const bucketIdx = bucketIndexForChart(chart);
if (bucketIdx === null) continue;
validCharts.get(bucketIdx).push(chart);
}
@@ -293,14 +308,15 @@ export function draw(gameData: GameData, configData: ConfigState): Drawing {
(sum, current) => sum + (current || 0),
0,
);
+ const evenRatios = numChartsToRandom % totalWeightUsed === 0;
for (const bucketIdx of validCharts.keys()) {
const normalizedWeight = (weights[bucketIdx] || 0) / totalWeightUsed;
const maxForThisBucket = Math.ceil(
normalizedWeight * numChartsToRandom,
);
maxDrawPerBucket.set(bucketIdx, maxForThisBucket);
- // setup minimum draws
- for (let i = 1; i < maxForThisBucket; i++) {
+ // setup minimum draws (even ratios means we use max, not min, so +1)
+ for (let i = evenRatios ? 0 : 1; i < maxForThisBucket; i++) {
requiredDrawIndexes.push(bucketIdx);
}
}
@@ -310,13 +326,42 @@ export function draw(gameData: GameData, configData: ConfigState): Drawing {
// OK, setup work is done, here's whre we actually draw the cards!
let redraw = false;
- const drawnCharts: DrawnChart[] = [];
+ let drawnCharts: DrawnChart[] = [];
+
+ let preSeededCharts = 0;
+ const preSeededDifficulties: number[] = [];
+ if ("charts" in startPoint) {
+ // account for the chart levels already in the draw starting point
+ preSeededCharts = startPoint.charts.length;
+ for (const chart of startPoint.charts) {
+ if (chart.type === "PLACEHOLDER") continue;
+ const bucketIdx = bucketIndexForChart(chart);
+ if (bucketIdx === null) continue;
+ // count this chart within quota
+ preSeededDifficulties.push(bucketIdx);
+
+ // remove from base requirements
+ const removeIdx = requiredDrawIndexes.indexOf(bucketIdx);
+ if (removeIdx >= 0) {
+ requiredDrawIndexes.splice(removeIdx, 1);
+ }
+ // remove this existing chart from eligible pool to prevent dupes
+ const bucket = validCharts.get(bucketIdx);
+ const idxInBucket = bucket.findIndex(
+ (eligibleChart) =>
+ eligibleChart.name === chart.name &&
+ chart.diffAbbr === eligibleChart.diffAbbr &&
+ chart.level === eligibleChart.level,
+ );
+ bucket.splice(idxInBucket, 1);
+ }
+ }
do {
/**
* Record of how many songs of each bucket index have been drawn so far
*/
- const difficultyCounts = new CountingSet();
+ const difficultyCounts = new CountingSet(preSeededDifficulties);
// make a copy of valid charts here in the loop so we
// can mutate it later during the draw process, but
@@ -329,7 +374,7 @@ export function draw(gameData: GameData, configData: ConfigState): Drawing {
localValidCharts.set(bucketIdx, charts.slice());
}
- while (drawnCharts.length < numChartsToRandom) {
+ while (drawnCharts.length + preSeededCharts < numChartsToRandom) {
if (bucketDistribution.length === 0) {
// no more songs available to pick in the requested range
// will be returning fewer than requested number of charts
@@ -385,22 +430,25 @@ export function draw(gameData: GameData, configData: ConfigState): Drawing {
redraw = false;
if (underDrawn || overDrawn) {
redraw = true;
- drawnCharts.splice(0, drawnCharts.length);
+ drawnCharts = [];
break;
}
}
}
} while (redraw);
- const charts: Drawing["charts"] = configData.sortByLevel
- ? drawnCharts.sort(
- (a, b) =>
- chartLevelOrTier(a, useGranularLevels, false) -
- chartLevelOrTier(b, useGranularLevels, false),
- )
- : shuffle(drawnCharts);
+ let charts: Drawing["charts"];
+ if (configData.sortByLevel) {
+ charts = drawnCharts.sort(
+ (a, b) =>
+ chartLevelOrTier(a, useGranularLevels, false) -
+ chartLevelOrTier(b, useGranularLevels, false),
+ );
+ } else {
+ charts = shuffle(drawnCharts);
+ }
- if (configData.playerPicks) {
+ if (!preSeededCharts && configData.playerPicks) {
charts.unshift(
...times(
configData.playerPicks,
@@ -412,13 +460,20 @@ export function draw(gameData: GameData, configData: ConfigState): Drawing {
);
}
+ const players =
+ startPoint.meta.type === "simple"
+ ? startPoint.meta.players
+ : startPoint.meta.entrants;
+
return {
id: `draw-${nanoid(10)}`,
+ configId: configData.id,
+ bans: {},
+ protects: {},
+ pocketPicks: {},
+ winners: {},
+ playerDisplayOrder: players.map((_, idx) => idx),
+ ...startPoint,
charts,
- players: times(defaultPlayersPerDraw, () => ""),
- bans: [],
- protects: [],
- pocketPicks: [],
- winners: [],
};
}
diff --git a/src/common-components/delayed-spinner.tsx b/src/common-components/delayed-spinner.tsx
new file mode 100644
index 000000000..5ee7451d0
--- /dev/null
+++ b/src/common-components/delayed-spinner.tsx
@@ -0,0 +1,18 @@
+import { Spinner } from "@blueprintjs/core";
+import { useState, useEffect } from "react";
+
+export function DelayedSpinner(props: { timeout?: number }) {
+ const [show, updateShow] = useState(false);
+ useEffect(() => {
+ if (show) return;
+
+ const timeout = setTimeout(() => {
+ updateShow(true);
+ }, props.timeout || 250);
+ return () => clearTimeout(timeout);
+ }, [props.timeout, show]);
+ if (show) {
+ return ;
+ }
+ return null;
+}
diff --git a/src/config-persistence.ts b/src/config-persistence.ts
index ea57dfbb0..f21aebfd6 100644
--- a/src/config-persistence.ts
+++ b/src/config-persistence.ts
@@ -1,36 +1,25 @@
-import { ConfigState, useConfigState } from "./config-state";
-import { useDrawState } from "./draw-state";
+import { ConfigState } from "./config-state";
import { toaster } from "./toaster";
import { buildDataUri, dateForFilename, shareData } from "./utils/share";
-interface PersistedConfigV1 {
- version: 1;
- dataSetName: string;
- configState: Serialized & OldSettings;
-}
+/** Mark specific fields in T optional, keeping others unchanged */
+// type Optional = Partial> &
+// Omit;
-/**
- * Returns a union of all property names in T which do not contain a function value.
- * Allows us to filter out mutations from a zustand store state.
- */
-type NonFunctionKeys = keyof {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
- [K in keyof T as T[K] extends Function ? never : K]: T[K];
-};
+interface PersistedConfigV2 {
+ version: 2;
+ configState: ConfigState;
+}
-/**
- * Strips mutations from an object, and converts sets to arrays, maps to arrays of entry pairs
- */
-type Serialized = {
- [K in NonFunctionKeys]: T[K] extends ReadonlyMap
- ? Array<[K, V]>
- : T[K] extends ReadonlySet
- ? Array-
- : T[K];
-};
+function buildPersistedConfig(config: ConfigState): PersistedConfigV2 {
+ return {
+ version: 2,
+ configState: config,
+ };
+}
-export function saveConfig() {
- const persistedObj = buildPersistedConfig();
+export function saveConfig(config: ConfigState) {
+ const persistedObj = buildPersistedConfig(config);
const dataUri = buildDataUri(
JSON.stringify(persistedObj, undefined, 2),
"application/json",
@@ -38,7 +27,7 @@ export function saveConfig() {
);
return shareData(dataUri, {
- filename: `ddr-tools-config-${persistedObj.dataSetName}-${dateForFilename()}.json`,
+ filename: `ddr-tools-config-${config.name.replaceAll(" ", "-")}-${dateForFilename()}.json`,
methods: [
{ type: "nativeShare", allowDesktop: true },
{ type: "download" },
@@ -46,38 +35,33 @@ export function saveConfig() {
});
}
-export function loadConfig() {
+export function loadConfig(): Promise {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".json,application/json";
fileInput.style.visibility = "hidden";
document.body.appendChild(fileInput);
- const resolution = new Promise((resolve, reject) => {
+ const resolution = new Promise((resolve, reject) => {
async function changeHandler() {
try {
const files = fileInput.files;
if (!files) {
- reject();
throw new Error("no file selected");
}
const f = files.item(0);
if (!f) {
- reject();
throw new Error("no file selected");
}
if (f.type !== "application/json") {
- reject();
throw new Error("file type is " + f.type);
}
- const contents: PersistedConfigV1 = JSON.parse(await f.text());
- await loadPersistedConfig(contents);
- resolve();
- toaster.show({
- message: "Successfully loaded draw settings",
- icon: "import",
- intent: "success",
- });
+ const contents: PersistedConfigV2 = JSON.parse(await f.text());
+ if (contents.version !== 2) {
+ throw new Error("config version was not expected value");
+ }
+ resolve(contents.configState);
} catch (e) {
+ reject();
toaster.show({
message: "Failed to load settings file",
icon: "error",
@@ -94,78 +78,3 @@ export function loadConfig() {
fileInput.click();
return resolution;
}
-
-function buildPersistedConfig(): PersistedConfigV1 {
- const { ...configState } = useConfigState.getState();
- const serializedState: PersistedConfigV1["configState"] = {
- ...configState,
- difficulties: Array.from(configState.difficulties),
- flags: Array.from(configState.flags),
- folders: Array.from(configState.folders),
- };
- const ret: PersistedConfigV1 = {
- version: 1,
- dataSetName: useDrawState.getState().dataSetName,
- configState: serializedState,
- };
- return ret;
-}
-
-async function loadPersistedConfig(saved: PersistedConfigV1) {
- if (saved.version !== 1) {
- return false;
- }
- const drawState = useDrawState.getState();
- if (drawState.dataSetName !== saved.dataSetName) {
- const nextConfigChange = new Promise((resolve) => {
- const unsub = useConfigState.subscribe(() => {
- unsub();
- resolve();
- });
- });
- await drawState.loadGameData(saved.dataSetName);
- // the ApplyDefaultConfig component will kick in
- // to overwrite config in response to this change
- // so we have to wait for that to happen before continuing
- await nextConfigChange;
- }
-
- useConfigState.setState({
- ...migrateOldNames(saved.configState),
- difficulties: new Set(saved.configState.difficulties),
- flags: new Set(saved.configState.flags),
- folders: new Set(saved.configState.folders),
- });
-}
-
-interface OldSettings {
- /** renamed to `showEligibleCharts` */
- showPool?: boolean;
- /** renamed to `showPlayerAndRoundLabels` */
- showLabels?: boolean;
-}
-
-function migrateOldNames(
- config: PersistedConfigV1["configState"],
-): Serialized {
- const { showPool, showLabels, ...modernConfig } = config;
-
- if (showPool) {
- modernConfig.showEligibleCharts = showPool;
- }
-
- if (showLabels) {
- modernConfig.showPlayerAndRoundLabels = showLabels;
- }
-
- const maybeOldWeights = modernConfig.weights as unknown as
- | Array<[number, number]>
- | Array;
- if (Array.isArray(maybeOldWeights[0])) {
- modernConfig.weights = maybeOldWeights.map((pair) =>
- Array.isArray(pair) ? pair[1] : pair,
- );
- }
-
- return modernConfig;
-}
diff --git a/src/config-state.ts b/src/config-state.ts
index b24d7ebe7..ca75fc398 100644
--- a/src/config-state.ts
+++ b/src/config-state.ts
@@ -1,67 +1,5 @@
-import type { StoreApi } from "zustand";
-import { createWithEqualityFn } from "zustand/traditional";
+import { atom } from "jotai";
-export interface ConfigState {
- chartCount: number;
- playerPicks: number;
- upperBound: number;
- lowerBound: number;
- useWeights: boolean;
- orderByAction: boolean;
- hideVetos: boolean;
- weights: Array;
- probabilityBucketCount: number | null;
- forceDistribution: boolean;
- constrainPocketPicks: boolean;
- style: string;
- folders: ReadonlySet;
- difficulties: ReadonlySet;
- flags: ReadonlySet;
- cutoffDate: string;
- showEligibleCharts: boolean;
- playerNames: string[];
- tournamentRounds: string[];
- showPlayerAndRoundLabels: boolean;
- defaultPlayersPerDraw: number;
- sortByLevel: boolean;
- useGranularLevels: boolean;
- update: StoreApi["setState"];
-}
+export const showPlayerAndRoundLabels = atom(true);
-export const useConfigState = createWithEqualityFn(
- (set) => ({
- chartCount: 5,
- playerPicks: 0,
- upperBound: 0,
- lowerBound: 0,
- useWeights: false,
- hideVetos: false,
- orderByAction: true,
- weights: [],
- probabilityBucketCount: null,
- forceDistribution: true,
- constrainPocketPicks: true,
- style: "",
- cutoffDate: "",
- folders: new Set(),
- difficulties: new Set(),
- flags: new Set(),
- showEligibleCharts: false,
- playerNames: [],
- tournamentRounds: [
- "Pools",
- "Winner's Bracket",
- "Winner's Finals",
- "Loser's Bracket",
- "Loser's Finals",
- "Grand Finals",
- "Tiebreaker",
- ],
- showPlayerAndRoundLabels: false,
- sortByLevel: false,
- defaultPlayersPerDraw: 2,
- useGranularLevels: false,
- update: set,
- }),
- Object.is,
-);
+export type { ConfigState } from "./state/config.slice";
diff --git a/src/controls/controls-drawer.tsx b/src/controls/controls-drawer.tsx
index e0fa53515..a04a2c0e2 100644
--- a/src/controls/controls-drawer.tsx
+++ b/src/controls/controls-drawer.tsx
@@ -3,21 +3,12 @@ import {
ButtonGroup,
Card,
Checkbox,
- Classes,
Collapse,
- Divider,
FormGroup,
HTMLSelect,
- Icon,
NumericInput,
- Tab,
- Tabs,
} from "@blueprintjs/core";
import {
- ThirdParty,
- GlobeNetwork,
- Settings,
- People,
CaretDown,
CaretRight,
Plus,
@@ -28,22 +19,20 @@ import { DateInput3 } from "@blueprintjs/datetime2";
import parse from "date-fns/parse";
import format from "date-fns/format";
import { useMemo, useState } from "react";
-import { shallow } from "zustand/shallow";
-import { useConfigState } from "../config-state";
-import { useDrawState } from "../draw-state";
-import { EligibleChartsListFilter } from "../eligible-charts/filter";
import { useIntl } from "../hooks/useIntl";
-import { useIsNarrow } from "../hooks/useMediaQuery";
import { GameData } from "../models/SongData";
-import { RemotePeerControls } from "../tournament-mode/remote-peer-menu";
-import { useRemotePeers } from "../tournament-mode/remote-peers";
import { WeightsControls } from "./controls-weights";
import styles from "./controls.css";
-import { PlayerNamesControls } from "./player-names";
-import { getAvailableLevels } from "../game-data-utils";
-import { ShowChartsToggle } from "./show-charts-toggle";
+import { getAvailableLevels, useGetMetaString } from "../game-data-utils";
import { Fraction } from "../utils/fraction";
import { detectedLanguage } from "../utils";
+import {
+ ConfigContextProvider,
+ useConfigState,
+ useGameData,
+ useUpdateConfig,
+} from "../state/hooks";
+import { useStockGameData } from "../state/game-data.atoms";
function getAvailableDifficulties(gameData: GameData, selectedStyle: string) {
const s = new Set();
@@ -82,46 +71,15 @@ function getDiffsAndRangeForNewStyle(
};
}
-export default function ControlsDrawer() {
- const { t } = useIntl();
- const isConnected = useRemotePeers((r) => !!r.thisPeer);
- const hasPeers = useRemotePeers((r) => !!r.remotePeers.size);
+export default function ControlsDrawer(props: { configId: string | null }) {
+ if (!props.configId) {
+ return null;
+ }
return (
-
- }
- panel={}
- >
- {t("controls.tabs.general")}
-
-
- ) : (
-
- )
- }
- intent={isConnected ? "success" : "none"}
- />
- }
- panel={}
- >
- {t("controls.tabs.networking")}
-
- }
- panel={}
- >
- {t("controls.tabs.players")}
-
-
+
+
+
);
}
@@ -129,11 +87,9 @@ export default function ControlsDrawer() {
const dateFormat = "yyyy-MM-dd";
function ReleaseDateFilter() {
const { t } = useIntl();
- const gameData = useDrawState((s) => s.gameData);
- const [updateState, cutoffDate] = useConfigState(
- (s) => [s.update, s.cutoffDate],
- shallow,
- );
+ const gameData = useGameData();
+ const updateState = useUpdateConfig();
+ const cutoffDate = useConfigState((s) => s.cutoffDate);
const mostRecentRelease = useMemo(
() =>
gameData?.songs.reduce((prev, song) => {
@@ -178,27 +134,25 @@ function ReleaseDateFilter() {
/** Renders the checkboxes for each individual flag that exists in the data file's meta.flags */
function FlagSettings() {
const { t } = useIntl();
- const [dataSetName, gameData, hasFlags] = useDrawState(
- (s) => [s.dataSetName, s.gameData, !!s.gameData?.meta.flags.length],
- shallow,
- );
- const [updateState, selectedFlags] = useConfigState(
- (s) => [s.update, s.flags],
- shallow,
- );
+ const gameData = useGameData();
+ const hasFlags = !!gameData?.meta.flags.length;
+ const updateState = useUpdateConfig();
+ const selectedFlags = useConfigState((s) => s.flags);
+ const getMetaString = useGetMetaString();
- if (!hasFlags) {
+ if (!hasFlags || !gameData) {
return false;
}
+ const dataSetName = gameData.i18n.en.name as string;
return (
{gameData?.meta.flags.map((key) => (
updateState((s) => {
const newFlags = new Set(s.flags);
@@ -207,7 +161,7 @@ function FlagSettings() {
} else {
newFlags.add(key);
}
- return { flags: newFlags };
+ return { flags: Array.from(newFlags) };
})
}
/>
@@ -219,34 +173,33 @@ function FlagSettings() {
/** Renders the checkboxes for each individual folder that exists in the data file's meta.folders */
function FolderSettings() {
const { t } = useIntl();
- const availableFolders = useDrawState((s) => s.gameData?.meta.folders);
- const dataSetName = useDrawState((s) => s.dataSetName);
- const [updateState, selectedFolders] = useConfigState(
- (s) => [s.update, s.folders],
- shallow,
- );
+ const gameData = useGameData();
+ const availableFolders = gameData?.meta.folders;
+ const updateState = useUpdateConfig();
+ const selectedFolders = useConfigState((s) => s.folders);
- if (!availableFolders?.length) {
+ if (!availableFolders?.length || !gameData) {
return null;
}
+ const dataSetName = gameData?.i18n.en.name as string;
return (
}
- onClick={() => updateState({ folders: new Set(availableFolders) })}
+ onClick={() => updateState({ folders: availableFolders })}
>
All
}
- onClick={() => updateState({ folders: new Set() })}
+ onClick={() => updateState({ folders: [] })}
>
Ignore Folders
@@ -256,7 +209,7 @@ function FolderSettings() {
key={`${dataSetName}:${idx}`}
label={folder}
value={folder}
- checked={selectedFolders.has(folder)}
+ checked={selectedFolders.includes(folder)}
onChange={() =>
updateState((s) => {
const newFolders = new Set(s.folders);
@@ -265,7 +218,7 @@ function FolderSettings() {
} else {
newFolders.add(folder);
}
- return { folders: newFolders };
+ return { folders: Array.from(newFolders) };
})
}
/>
@@ -276,8 +229,9 @@ function FolderSettings() {
function GeneralSettings() {
const { t } = useIntl();
- const gameData = useDrawState((s) => s.gameData);
+ const updateState = useUpdateConfig();
const configState = useConfigState();
+ const gameData = useStockGameData(configState.gameKey);
const {
useWeights,
constrainPocketPicks,
@@ -285,7 +239,6 @@ function GeneralSettings() {
hideVetos,
lowerBound,
upperBound,
- update: updateState,
difficulties: selectedDifficulties,
style: selectedStyle,
chartCount,
@@ -299,12 +252,12 @@ function GeneralSettings() {
}
return getAvailableDifficulties(gameData, selectedStyle);
}, [gameData, selectedStyle]);
- const isNarrow = useIsNarrow();
const [expandFilters, setExpandFilters] = useState(false);
const availableLevels = useMemo(
() => getAvailableLevels(gameData, useGranularLevels),
[gameData, useGranularLevels],
);
+ const getMetaString = useGetMetaString();
if (!gameData) {
return null;
@@ -361,21 +314,6 @@ function GeneralSettings() {
return (
<>
- {isNarrow && (
- <>
-
-
-
-
-
-
-
-
-
- >
- )}
{
if (!isNaN(chartCount)) {
- updateState(() => {
- return { chartCount };
- });
+ updateState({ chartCount });
}
}}
/>
@@ -413,9 +349,7 @@ function GeneralSettings() {
clampValueOnBlur
onValueChange={(playerPicks) => {
if (!isNaN(playerPicks)) {
- updateState(() => {
- return { playerPicks };
- });
+ updateState({ playerPicks });
}
}}
/>
@@ -490,7 +424,7 @@ function GeneralSettings() {
next.style,
);
if (diffs.length === 1) {
- next.difficulties = new Set(diffs.map((d) => d.key));
+ next.difficulties = diffs.map((d) => d.key);
}
if (lvlRange.low > next.upperBound) {
next.upperBound = lvlRange.low;
@@ -504,7 +438,7 @@ function GeneralSettings() {
>
{gameStyles.map((style) => (
))}
@@ -516,7 +450,7 @@ function GeneralSettings() {
key={`${dif.key}`}
name="difficulties"
value={dif.key}
- checked={selectedDifficulties.has(dif.key)}
+ checked={selectedDifficulties.includes(dif.key)}
onChange={(e) => {
const { checked, value } = e.currentTarget;
updateState((s) => {
@@ -526,10 +460,10 @@ function GeneralSettings() {
} else {
difficulties.delete(value);
}
- return { difficulties };
+ return { difficulties: Array.from(difficulties) };
});
}}
- label={t("meta." + dif.key)}
+ label={getMetaString(dif.key)}
/>
))}
diff --git a/src/controls/controls-weights.tsx b/src/controls/controls-weights.tsx
index c9eb63922..fd3e61a4f 100644
--- a/src/controls/controls-weights.tsx
+++ b/src/controls/controls-weights.tsx
@@ -2,10 +2,9 @@ import { shallow } from "zustand/shallow";
import styles from "./controls-weights.css";
import { zeroPad } from "../utils";
import { useMemo } from "react";
-import { useConfigState } from "../config-state";
+import { useConfigState, useGameData, useUpdateConfig } from "../state/hooks";
import { useIntl } from "../hooks/useIntl";
import { NumericInput, Checkbox, Classes } from "@blueprintjs/core";
-import { useDrawState } from "../draw-state";
import { getAvailableLevels } from "../game-data-utils";
import { LevelRangeBucket, getBuckets } from "../card-draw";
@@ -33,12 +32,12 @@ function printGroup(
export function WeightsControls({ usesTiers, high, low }: Props) {
const { t } = useIntl();
+ const updateConfig = useUpdateConfig();
const {
weights,
useWeights,
forceDistribution,
bucketCount,
- updateConfig,
totalToDraw,
useGranularLevels,
} = useConfigState(
@@ -47,13 +46,12 @@ export function WeightsControls({ usesTiers, high, low }: Props) {
weights: cfg.weights,
forceDistribution: cfg.forceDistribution,
bucketCount: cfg.probabilityBucketCount,
- updateConfig: cfg.update,
totalToDraw: cfg.chartCount,
useGranularLevels: cfg.useGranularLevels,
}),
shallow,
);
- const gameData = useDrawState((s) => s.gameData);
+ const gameData = useGameData();
const groups = useMemo(() => {
const availableLevels = getAvailableLevels(gameData, useGranularLevels);
return Array.from(
diff --git a/src/controls/degrs-tester.tsx b/src/controls/degrs-tester.tsx
index 3f6691ad8..e88f647e3 100644
--- a/src/controls/degrs-tester.tsx
+++ b/src/controls/degrs-tester.tsx
@@ -3,12 +3,12 @@ import {
Callout,
Dialog,
DialogBody,
+ FormGroup,
ProgressBar,
} from "@blueprintjs/core";
import { draw } from "../card-draw";
-import { useDrawState } from "../draw-state";
import { useAtom } from "jotai";
-import { useConfigState } from "../config-state";
+import { configSlice } from "../state/config.slice";
import { requestIdleCallback } from "../utils/idle-callback";
import {
TEST_SIZE,
@@ -21,17 +21,28 @@ import { SongCard, SongCardProps } from "../song-card/song-card";
import { useState } from "react";
import { Rain, Repeat, WarningSign } from "@blueprintjs/icons";
import { EligibleChart, PlayerPickPlaceholder } from "../models/Drawing";
+import { store } from "../state/store";
+import { GameData } from "../models/SongData";
+import { useGameData } from "../state/hooks";
+import { ConfigSelect } from ".";
export function isDegrs(thing: EligibleChart | PlayerPickPlaceholder) {
return "name" in thing && thing.name.startsWith('DEAD END("GROOVE');
}
-function* oneMillionDraws() {
- const gameData = useDrawState.getState().gameData!;
- const configState = useConfigState.getState();
+function* oneMillionDraws(gameData: GameData, configId: string) {
+ const configState = configSlice.selectors.selectById(
+ store.getState(),
+ configId,
+ );
for (let idx = 0; idx < TEST_SIZE; idx++) {
- yield [draw(gameData, configState), idx] as const;
+ yield [
+ draw(gameData, configState!, {
+ meta: { players: [], title: "", type: "simple" },
+ }),
+ idx,
+ ] as const;
}
}
@@ -40,9 +51,9 @@ function* oneMillionDraws() {
* yields current progress every 1000 loops to allow passing back
* the event loop
**/
-export function* degrsTester() {
+export function* degrsTester(gameData: GameData, configId: string) {
let totalDegrs = 0;
- for (const [set, idx] of oneMillionDraws()) {
+ for (const [set, idx] of oneMillionDraws(gameData, configId)) {
if (set.charts.some(isDegrs)) {
totalDegrs++;
}
@@ -59,17 +70,18 @@ function nextIdleCycle() {
});
}
-export function DegrsTestButton() {
+export function DegrsTestButton(props: { configId: string | null }) {
const [isTesting, setIsTesting] = useAtom(degrsIsTesting);
const [progress, setProgress] = useAtom(degrsTestProgress);
const [results, setResults] = useAtom(degrsTestResults);
+ const gameData = useGameData();
async function startTest() {
setIsTesting(true);
setProgress(0);
setResults(undefined);
await nextIdleCycle();
- const tester = degrsTester();
+ const tester = degrsTester(gameData!, props.configId!);
let report = tester.next();
while (!report.done) {
setProgress(report.value);
@@ -88,6 +100,7 @@ export function DegrsTestButton() {
{!isTesting && (
:
}
>
@@ -111,6 +124,7 @@ export function DegrsTestButton() {
export function TesterCard(props: SongCardProps) {
const [isOpen, setIsOpen] = useState(false);
+ const [configId, setConfigId] = useState
(null);
return (
<>
}
>
-
+
+
+
+
setIsOpen(true)} />
diff --git a/src/controls/index.tsx b/src/controls/index.tsx
index 4a04ee954..5b6f17bdb 100644
--- a/src/controls/index.tsx
+++ b/src/controls/index.tsx
@@ -1,39 +1,190 @@
import {
Button,
ButtonGroup,
+ Classes,
+ Dialog,
+ DialogBody,
+ DialogFooter,
Drawer,
DrawerSize,
+ FormGroup,
+ HTMLSelect,
+ InputGroup,
Intent,
- NavbarDivider,
+ Menu,
+ MenuDivider,
+ MenuItem,
+ Popover,
Position,
Spinner,
+ Tab,
+ Tabs,
+ TagInput,
Tooltip,
} from "@blueprintjs/core";
-import { NewLayers, Cog, FloppyDisk, Import } from "@blueprintjs/icons";
-import { useState, lazy, Suspense } from "react";
+import {
+ NewLayers,
+ Cog,
+ Add,
+ Duplicate,
+ Trash,
+ FloppyDisk,
+ Import,
+ Menu as MenuIcon,
+} from "@blueprintjs/icons";
+import { useState, lazy, Suspense, useRef } from "react";
import { FormattedMessage } from "react-intl";
-import { useConfigState } from "../config-state";
-import { useDrawState } from "../draw-state";
import { useIsNarrow } from "../hooks/useMediaQuery";
-import { loadConfig, saveConfig } from "../config-persistence";
import { ErrorBoundary } from "react-error-boundary";
import { ErrorFallback } from "../utils/error-fallback";
-import { ShowChartsToggle } from "./show-charts-toggle";
+import {
+ createConfigFromImport,
+ createConfigFromInputs,
+ createDraw,
+} from "../state/thunks";
+import { createAppSelector, useAppDispatch, useAppState } from "../state/store";
+import { GauntletPicker, MatchPicker, PickedMatch } from "../matches";
+import { StartggApiKeyGated } from "../startgg-gql/components";
+import { configSlice, ConfigState } from "../state/config.slice";
+import { GameDataSelect } from "../version-select";
+import { loadConfig, saveConfig } from "../config-persistence";
+import { SimpleMeta } from "../models/Drawing";
const ControlsDrawer = lazy(() => import("./controls-drawer"));
+const getConfigSummaryValues = createAppSelector(
+ [(s) => s.config.entities],
+ (entities) =>
+ Object.entries(entities).map(
+ ([key, config]) =>
+ [
+ key,
+ config.name,
+ config.gameKey,
+ config.lowerBound,
+ config.upperBound,
+ ] as const,
+ ),
+);
+
+export function ConfigSelect(props: {
+ selectedId: string | null;
+ onChange(nextId: string): void;
+ createDirection?: "left" | "right";
+}) {
+ const summaryValues = useAppState(getConfigSummaryValues);
+
+ if (!summaryValues.length) {
+ let emptyMsg = "no configs created";
+ switch (props.createDirection) {
+ case "left":
+ emptyMsg = "👈 Create a config here";
+ break;
+ case "right":
+ emptyMsg = "Create a config here 👉";
+ break;
+ }
+ return (
+
+
+
+ );
+ }
+
+ return (
+ props.onChange(e.currentTarget.value)}
+ >
+
+ {summaryValues.map(([key, name, gameKey, lb, ub]) => (
+
+ ))}
+
+ );
+}
+
+function CustomDrawForm(props: {
+ initialMeta?: SimpleMeta;
+ disableCreate?: boolean;
+ onSubmit(meta: SimpleMeta): void;
+}) {
+ const [players, setPlayers] = useState(
+ props.initialMeta?.players || [],
+ );
+ const [title, setTitle] = useState(props.initialMeta?.title || "");
+
+ function handleSubmit() {
+ props.onSubmit({
+ type: "simple",
+ players,
+ title,
+ });
+ }
+ return (
+ <>
+
+ setTitle(e.currentTarget.value)}
+ />
+
+
+ setPlayers(v as string[])}
+ values={players}
+ />
+
+
+ >
+ );
+}
+
export function HeaderControls() {
+ const [configId, setConfigId] = useState(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [lastDrawFailed, setLastDrawFailed] = useState(false);
- const [drawSongs, hasGameData] = useDrawState((s) => [
- s.drawSongs,
- !!s.gameData,
- ]);
+ const [matchPickerOpen, setMatchPickerOpen] = useState(false);
+ const hasAnyConfig = useAppState((s) => !!s.config.ids.length);
const isNarrow = useIsNarrow();
+ const dispatch = useAppDispatch();
- function handleDraw() {
- useConfigState.setState({ showEligibleCharts: false });
- drawSongs(useConfigState.getState());
+ function handleDraw(match: PickedMatch) {
+ if (!configId) {
+ return;
+ }
+ setMatchPickerOpen(false);
+ const result = dispatch(
+ createDraw(
+ {
+ meta: {
+ type: "startgg",
+ subtype: match.subtype,
+ entrants: match.players,
+ title: match.title,
+ id: match.id,
+ },
+ },
+ configId,
+ ),
+ );
+ if (typeof result === "boolean") {
+ setLastDrawFailed(result);
+ } else {
+ setLastDrawFailed(false);
+ }
}
function openSettings() {
@@ -43,48 +194,60 @@ export function HeaderControls() {
return (
<>
- setSettingsOpen(false)}
- title={
- <>
-
-
- } onClick={saveConfig}>
- Save
-
- } onClick={loadConfig}>
- Load
-
-
- >
- }
+ />
+
- {!isNarrow && (
- <>
-
-
- >
- )}
+
+
+
+
+
+ dispatch(createDraw({ meta }, configId!))}
+ />
+ }
+ >
+ custom draw
+
+
+
+
+ }
+ >
+ start.gg (h2h)
+
+
+
+
+ }
+ >
+ start.gg (gauntlet)
+
+
+
+
-
- }
- intent={Intent.PRIMARY}
- disabled={!hasGameData}
- >
-
-
-
}
@@ -92,13 +255,215 @@ export function HeaderControls() {
usePortal={false}
position={Position.BOTTOM_RIGHT}
>
- }
- onClick={openSettings}
- data-umami-event="settings-open"
- />
+
+
+
+ }
+ onClick={openSettings}
+ data-umami-event="settings-open"
+ />
>
);
}
+
+function MultiControlsDrawer(props: {
+ isOpen: boolean;
+ isNarrow: boolean;
+ onClose(): void;
+}) {
+ const [configId, setConfigId] = useState(null);
+
+ return (
+
+
+
+ >
+ }
+ >
+ }>
+ }>
+
+
+
+
+ );
+}
+
+/**
+ * provides UI for selecting/creating/cloning/import/exporting configs at the top of the config drawer
+ */
+function MultiControlsManager(props: {
+ configId: string | null;
+ onConfigSelected(configId: string): void;
+}) {
+ const selected = props.configId;
+ const selectedName = useAppState((s) =>
+ selected ? s.config.entities[selected].name : undefined,
+ );
+ const selectedGameId = useAppState((s) =>
+ selected ? s.config.entities[selected].gameKey : undefined,
+ );
+ const [addOpen, setAddOpen] = useState(false);
+ const [busyCreating, setBusyCreating] = useState(false);
+ const dispatch = useAppDispatch();
+ const createBasisRef = useRef();
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (busyCreating) return;
+
+ const data = new FormData(e.currentTarget);
+ const name = data.get("name") as string;
+ const gameStub = data.get("game") as string;
+
+ if (!name) {
+ return;
+ }
+ if (!gameStub) {
+ return;
+ }
+
+ setBusyCreating(true);
+ const action =
+ typeof createBasisRef.current === "object"
+ ? createConfigFromImport(name, gameStub, createBasisRef.current)
+ : createConfigFromInputs(name, gameStub, createBasisRef.current);
+ const newConfig = await dispatch(action);
+ setAddOpen(false);
+ setBusyCreating(false);
+ createBasisRef.current = undefined;
+ props.onConfigSelected(newConfig.id);
+ };
+
+ function titleFromBasis() {
+ switch (typeof createBasisRef.current) {
+ case "object":
+ return "Import config";
+ case "string":
+ return "Duplicate config";
+ default:
+ return "Create config";
+ }
+ }
+
+ function defaultNameFromBasis() {
+ switch (typeof createBasisRef.current) {
+ case "object":
+ return `copy of ${createBasisRef.current.name}`;
+ case "string":
+ return `copy of ${selectedName}`;
+ default:
+ return "";
+ }
+ }
+
+ return (
+ <>
+
+
+ }
+ onClick={() => setAddOpen(true)}
+ />
+ }
+ onClick={() => {
+ createBasisRef.current = selected || undefined;
+ setAddOpen(true);
+ }}
+ />
+ }
+ onClick={() => dispatch(configSlice.actions.removeOne(selected!))}
+ />
+
+ }
+ onClick={async () => {
+ createBasisRef.current = await loadConfig();
+ setAddOpen(true);
+ }}
+ />
+ }
+ onClick={() =>
+ saveConfig(dispatch((d, gs) => gs().config.entities[selected!]))
+ }
+ />
+
+ }
+ >
+ } />
+
+
+ >
+ );
+}
diff --git a/src/controls/player-names.tsx b/src/controls/player-names.tsx
index ccfd5dbbc..3d9068097 100644
--- a/src/controls/player-names.tsx
+++ b/src/controls/player-names.tsx
@@ -1,133 +1,83 @@
-import {
- Checkbox,
- Classes,
- FormGroup,
- NumericInput,
- TagInput,
-} from "@blueprintjs/core";
-import { ReactNode } from "react";
-import { useConfigState } from "../config-state";
-import { useIntl } from "../hooks/useIntl";
-import { DiagramTree, Person } from "@blueprintjs/icons";
+import { Section, SectionCard } from "@blueprintjs/core";
+// import { useConfigState, useUpdateConfig } from "../state/hooks";
+// import { useIntl } from "../hooks/useIntl";
+import { useAtomValue } from "jotai";
+// import { showPlayerAndRoundLabels } from "../config-state";
+// import { useAppState } from "../state/store";
+import { startggEventSlug, startggKeyAtom } from "../startgg-gql";
+import { StartggCredsManager } from "../startgg-gql/components";
export function PlayerNamesControls() {
- const { t } = useIntl();
- const playerNames = useConfigState((s) => s.playerNames);
- const updateConfig = useConfigState((s) => s.update);
-
- function addPlayers(names: string[]) {
- updateConfig((prev) => {
- const next = prev.playerNames.slice();
- for (const name of names) {
- if (!next.includes(name)) {
- next.push(name);
- }
- }
- if (next.length !== prev.playerNames.length) {
- return { playerNames: next };
- }
- return {};
- });
- }
- function removePlayer(name: ReactNode, index: number) {
- updateConfig((prev) => {
- const next = prev.playerNames.slice();
- next.splice(index, 1);
- return { playerNames: next };
- });
- }
-
+ const apiKey = useAtomValue(startggKeyAtom);
+ const eventSlug = useAtomValue(startggEventSlug);
return (
<>
-
-
-
- }
- onAdd={addPlayers}
- onRemove={removePlayer}
- />
-
-
+
>
);
}
-function ShowLabelsToggle() {
- const update = useConfigState((s) => s.update);
- const enabled = useConfigState((s) => s.showPlayerAndRoundLabels);
- const { t } = useIntl();
-
- return (
-
- update({ showPlayerAndRoundLabels: e.currentTarget.checked })
- }
- label={t("controls.playerLabels")}
- />
- );
+export function inferShortname(name: string): string;
+export function inferShortname(
+ name: string | null | undefined,
+): string | undefined;
+export function inferShortname(name: string | null | undefined) {
+ if (!name) return;
+ const namePieces = name.split(" | ");
+ return namePieces.length >= 1 ? namePieces[namePieces.length - 1] : undefined;
}
-function PlayersPerDraw() {
- const update = useConfigState((s) => s.update);
- const ppd = useConfigState((s) => s.defaultPlayersPerDraw);
- const { t } = useIntl();
+// function EntrantNameForm(props: { entrant: Entrant }) {
+// return (
+//
+// );
+// }
- return (
-
- update({ defaultPlayersPerDraw: next })}
- />
-
- );
-}
+// function ShowLabelsToggle() {
+// const [enabled, updateShowLabels] = useAtom(showPlayerAndRoundLabels);
+// const { t } = useIntl();
-function TournamentLabelEditor() {
- const { t } = useIntl();
- const tournamentRounds = useConfigState((s) => s.tournamentRounds);
- const updateConfig = useConfigState((s) => s.update);
+// return (
+// updateShowLabels(e.currentTarget.checked)}
+// label={t("controls.playerLabels")}
+// />
+// );
+// }
- function addLabels(names: string[]) {
- updateConfig((prev) => {
- const next = prev.tournamentRounds.slice();
- for (const name of names) {
- if (!next.includes(name)) {
- next.push(name);
- }
- }
- if (next.length !== prev.tournamentRounds.length) {
- return { tournamentRounds: next };
- }
- return {};
- });
- }
- function removeLabel(name: ReactNode, index: number) {
- updateConfig((prev) => {
- const next = prev.tournamentRounds.slice();
- next.splice(index, 1);
- return { tournamentRounds: next };
- });
- }
- return (
-
- }
- onAdd={addLabels}
- onRemove={removeLabel}
- />
-
- );
-}
+// function PlayersPerDraw() {
+// const update = useUpdateConfig();
+// const ppd = useConfigState((s) => s.defaultPlayersPerDraw);
+// const { t } = useIntl();
+
+// return (
+//
+// update({ defaultPlayersPerDraw: next })}
+// />
+//
+// );
+// }
diff --git a/src/controls/show-charts-toggle.css b/src/controls/show-charts-toggle.css
deleted file mode 100644
index b44e92a91..000000000
--- a/src/controls/show-charts-toggle.css
+++ /dev/null
@@ -1,3 +0,0 @@
-.showAllToggle {
- margin-bottom: 0;
-}
diff --git a/src/controls/show-charts-toggle.tsx b/src/controls/show-charts-toggle.tsx
deleted file mode 100644
index 00c82c0b4..000000000
--- a/src/controls/show-charts-toggle.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { Switch } from "@blueprintjs/core";
-import { useConfigState } from "../config-state";
-import { useIntl } from "../hooks/useIntl";
-import { shallow } from "zustand/shallow";
-import styles from "./show-charts-toggle.css";
-
-export function ShowChartsToggle({ inDrawer }: { inDrawer: boolean }) {
- const { t } = useIntl();
- const { showEligible, update } = useConfigState(
- (state) => ({
- showEligible: state.showEligibleCharts,
- update: state.update,
- }),
- shallow,
- );
- return (
- {
- update({
- showEligibleCharts: !!e.currentTarget.checked,
- });
- }}
- />
- );
-}
diff --git a/src/draw-state.tsx b/src/draw-state.tsx
deleted file mode 100644
index 9503911ae..000000000
--- a/src/draw-state.tsx
+++ /dev/null
@@ -1,233 +0,0 @@
-import { ReactNode, useEffect } from "react";
-import { UnloadHandler } from "./unload-handler";
-import { draw } from "./card-draw";
-import { Drawing } from "./models/Drawing";
-import FuzzySearch from "fuzzy-search";
-import { requestIdleCallback, cancelIdleCallback } from "./utils/idle-callback";
-import { GameData, I18NDict, Song } from "./models/SongData";
-import i18nData from "./assets/i18n.json";
-import { availableGameData, detectedLanguage } from "./utils";
-import { ApplyDefaultConfig } from "./apply-default-config";
-import { ConfigState } from "./config-state";
-import { IntlProvider } from "./intl-provider";
-import type { StoreApi } from "zustand";
-import { createWithEqualityFn } from "zustand/traditional";
-import { shallow } from "zustand/shallow";
-import { DataConnection } from "peerjs";
-
-interface DrawState {
- importedData: Map;
- gameData: GameData | null;
- fuzzySearch: FuzzySearch | null;
- drawings: Drawing[];
- dataSetName: string;
- lastDrawFailed: boolean;
- addImportedData(dataSetName: string, gameData: GameData): void;
- loadGameData(dataSetName: string, gameData?: GameData): Promise;
- /** returns false if no songs could be drawn */
- drawSongs(config: ConfigState): boolean;
- clearDrawings(): void;
- injectRemoteDrawing(d: Drawing, syncWithPeer?: DataConnection): void;
-}
-
-function applyNewData(data: GameData, set: StoreApi["setState"]) {
- set({
- gameData: data,
- drawings: [],
- fuzzySearch: new FuzzySearch(
- data.songs,
- [
- "name",
- "name_translation",
- "search_hint",
- "artist",
- "artist_translation",
- ],
- {
- sort: true,
- },
- ),
- });
-}
-
-declare const umami: {
- track(
- eventName?: string,
- eventProperties?: Record,
- ): void;
-};
-
-function trackDraw(count: number | null, game?: string) {
- if (typeof umami === "undefined") {
- return;
- }
- const results =
- count === null ? { result: "failed" } : { result: "success", count, game };
- umami.track("cards-drawn", results);
-}
-
-export const useDrawState = createWithEqualityFn(
- (set, get) => ({
- importedData: new Map(),
- gameData: null,
- fuzzySearch: null,
- drawings: [],
- dataSetName: "",
- lastDrawFailed: false,
- clearDrawings() {
- if (
- get().drawings.length &&
- !window.confirm("This will clear all songs drawn so far. Confirm?")
- ) {
- return;
- }
- set({ drawings: [] });
- },
- addImportedData(dataSetName, gameData) {
- const { importedData } = get();
- const nextData = new Map(importedData);
- nextData.set(dataSetName, gameData);
- set({
- importedData: nextData,
- dataSetName,
- });
- writeDataSetToUrl(dataSetName);
- applyNewData(gameData, set);
- },
- async loadGameData(dataSetName: string, gameData?: GameData) {
- const state = get();
- if (state.dataSetName === dataSetName && state.gameData) {
- return state.gameData;
- }
- if (
- state.drawings.length &&
- !window.confirm("This will clear all songs drawn so far. Confirm?")
- ) {
- return state.gameData;
- }
- set({
- gameData: null,
- dataSetName,
- drawings: [],
- });
- writeDataSetToUrl(dataSetName);
-
- // Attempt to look up a local data file first
- gameData = state.importedData.get(dataSetName);
-
- const data =
- gameData ||
- (
- await import(
- /* webpackChunkName: "songData" */ `./songs/${dataSetName}.json`
- )
- ).default;
- applyNewData(data, set);
- return data;
- },
- drawSongs(config: ConfigState) {
- const state = get();
- if (!state.gameData) {
- trackDraw(null);
- return false;
- }
-
- const drawing = draw(state.gameData, config);
- trackDraw(drawing.charts.length, state.dataSetName);
- if (!drawing.charts.length) {
- set({
- lastDrawFailed: true,
- });
- return false;
- }
-
- set((prevState) => {
- return {
- drawings: [drawing, ...prevState.drawings].filter(Boolean),
- lastDrawFailed: false,
- };
- });
- return true;
- },
- injectRemoteDrawing(drawing, syncWithPeer) {
- set((prevState) => {
- const currentDrawing = prevState.drawings.find(
- (d) => d.id === drawing.id,
- );
- const newDrawings = prevState.drawings.filter(
- (d) => d.id !== drawing.id,
- );
- newDrawings.unshift(drawing);
- if (currentDrawing) {
- drawing.__syncPeer = currentDrawing.__syncPeer;
- }
- if (syncWithPeer) {
- drawing.__syncPeer = syncWithPeer;
- }
- return {
- drawings: newDrawings,
- };
- });
- },
- }),
- Object.is,
-);
-
-interface Props {
- defaultDataSet: string;
- children: ReactNode;
-}
-
-function getInitialDataSet(defaultDataName: string) {
- const hash = window.location.hash.slice(1);
- if (hash.startsWith("game-")) {
- const targetData = hash.slice(5);
- if (availableGameData.some((d) => d.name === targetData)) {
- return targetData;
- }
- }
- if (
- defaultDataName &&
- availableGameData.some((d) => d.name === defaultDataName)
- ) {
- return defaultDataName;
- }
- return availableGameData[0].name;
-}
-
-function writeDataSetToUrl(game: string) {
- const nextHash = `game-${game}`;
- if ("#" + nextHash !== window.location.hash) {
- const nextUrl = new URL(window.location.href);
- nextUrl.hash = encodeURIComponent(nextHash);
- window.history.replaceState(undefined, "", nextUrl);
- }
-}
-
-export function DrawStateManager(props: Props) {
- const [gameData, hasDrawings, loadGameData] = useDrawState(
- (state) => [state.gameData, !!state.drawings.length, state.loadGameData],
- shallow,
- );
- useEffect(() => {
- const idleHandle = requestIdleCallback(() =>
- loadGameData(getInitialDataSet(props.defaultDataSet)),
- );
- return () => cancelIdleCallback(idleHandle);
- }, [loadGameData, props.defaultDataSet]);
-
- return (
- }
- mergeTranslations={gameData?.i18n}
- >
-
-
- {props.children}
-
- );
-}
diff --git a/src/drawing-context.tsx b/src/drawing-context.tsx
index 81bed2acd..ee6a6f5bf 100644
--- a/src/drawing-context.tsx
+++ b/src/drawing-context.tsx
@@ -1,215 +1,49 @@
-import { ReactNode } from "react";
-import { StoreApi } from "zustand";
-import { draw } from "./card-draw";
-import { useConfigState } from "./config-state";
-import { createContextualStore } from "./zustand/contextual-zustand";
-import { useDrawState } from "./draw-state";
-import {
- CHART_PLACEHOLDER,
- Drawing,
- EligibleChart,
- PlayerActionOnChart,
- PocketPick,
-} from "./models/Drawing";
-import { SerializibleStore } from "./zustand/shared-zustand";
+import { createContext, useContext } from "react";
+import { drawingSelectors, drawingsSlice } from "./state/drawings.slice";
+import { Drawing } from "./models/Drawing";
+import { useAppDispatch, useAppState } from "./state/store";
+import { EqualityFn } from "react-redux";
const stubDrawing: Drawing = {
- id: "stub",
- players: [],
+ id: "",
+ configId: "",
+ meta: {
+ type: "simple",
+ players: [],
+ title: "",
+ },
+ playerDisplayOrder: [],
+ bans: {},
charts: [],
- bans: [],
- pocketPicks: [],
- protects: [],
- winners: [],
+ pocketPicks: {},
+ protects: {},
+ winners: {},
};
-interface DrawingProviderProps {
- initialDrawing: Drawing;
- children?: ReactNode;
+const context = createContext("");
+const DrawingProvider = context.Provider;
+
+function useDrawing(
+ selector: (d: Drawing) => T,
+ equalityFn?: EqualityFn,
+) {
+ const drawingId = useContext(context);
+ return useAppState((state) => {
+ const drawing =
+ drawingSelectors.selectById(state, drawingId) || stubDrawing;
+ return selector(drawing);
+ }, equalityFn);
}
-export interface DrawingContext extends Drawing, SerializibleStore {
- updateDrawing: StoreApi["setState"];
- incrementPriorityPlayer(): void;
- redrawAllCharts(): void;
- redrawChart(chartId: string): void;
- resetChart(chartId: string): void;
- /**
- * handles any of the protect/pocket-pick/ban actions a user may take on a drawn chart
- * @param action type of action being performed
- * @param chartId id of the chart being acted upon
- * @param player the player acting on the chart, 1 or 2
- * @param chart new chart being pocket picked, if this is a pocket pick action
- */
- handleBanProtectReplace(
- action: "ban" | "protect" | "pocket",
- chartId: string,
- player: number,
- chart?: EligibleChart,
- ): void;
- setWinner(chartId: string, p: number | null): void;
-}
+export function useUpdateDrawing(): (changes: Partial) => void {
+ const drawingId = useContext(context);
+ const dispatch = useAppDispatch();
-function keyFromAction(action: "ban" | "protect" | "pocket") {
- switch (action) {
- case "ban":
- return "bans";
- case "protect":
- return "protects";
- case "pocket":
- return "pocketPicks";
+ if (!drawingId) {
+ return () => {};
}
+ return (changes) =>
+ dispatch(drawingsSlice.actions.updateOne({ id: drawingId, changes }));
}
-const {
- Provider: DrawingProvider,
- useContextValue: useDrawing,
- StoreIndex: allDrawingStores,
- useStore: useDrawingStore,
-} = createContextualStore(
- (props, set, get) => ({
- ...props.initialDrawing,
- updateDrawing: set,
- incrementPriorityPlayer() {
- set((d) => {
- let priorityPlayer = d.priorityPlayer;
- if (!priorityPlayer) {
- priorityPlayer = 1;
- } else {
- priorityPlayer += 1;
- if (priorityPlayer >= d.players.length + 1) {
- priorityPlayer = undefined;
- }
- }
- return {
- priorityPlayer,
- };
- });
- },
- resetChart(chartId) {
- set((d) => ({
- bans: d.bans.filter((p) => p.chartId !== chartId),
- protects: d.protects.filter((p) => p.chartId !== chartId),
- pocketPicks: d.pocketPicks.filter((p) => p.chartId !== chartId),
- winners: d.pocketPicks.filter((p) => p.chartId !== chartId),
- }));
- },
- redrawChart(chartId) {
- const newChart = draw(useDrawState.getState().gameData!, {
- ...useConfigState.getState(),
- chartCount: 1,
- }).charts[0];
- set((d) => ({
- charts: d.charts.map((chart) => {
- if (chart.id === chartId) {
- newChart.id = chartId;
- return newChart;
- }
- return chart;
- }),
- }));
- },
- redrawAllCharts() {
- const self = get();
- const keepChartIds = new Set([
- ...self.pocketPicks.map((pick) => pick.chartId),
- ...self.protects.map((pick) => pick.chartId),
- ]);
- const keepCharts = self.charts.filter(
- (c) => keepChartIds.has(c.id) || c.type === CHART_PLACEHOLDER,
- );
- const newCharts = draw(useDrawState.getState().gameData!, {
- ...useConfigState.getState(),
- chartCount: get().charts.length - keepCharts.length,
- });
- set(() => ({
- charts: [...keepCharts, ...newCharts.charts],
- bans: [],
- }));
- },
- handleBanProtectReplace(action, chartId, player, newChart) {
- const drawing = get();
- const charts = drawing.charts.slice();
- const key = keyFromAction(action);
- const arr = drawing[key].slice() as PlayerActionOnChart[] | PocketPick[];
- const targetChartIdx = charts.findIndex((chart) => chart.id === chartId);
- const targetChart = charts[targetChartIdx];
-
- if (
- useConfigState.getState().orderByAction &&
- targetChart?.type !== CHART_PLACEHOLDER
- ) {
- charts.splice(targetChartIdx, 1);
- if (action === "ban") {
- // insert at tail of list
- const insertPoint = charts.length;
- charts.splice(insertPoint, 0, targetChart);
- } else {
- const frontLockedCardCount =
- // number of placeholder cards total (picked and unpicked)
- charts.reduce(
- (total, curr) =>
- total + (curr.type === CHART_PLACEHOLDER ? 1 : 0),
- 0,
- ) +
- // number of protects
- drawing.protects.length +
- // number of picks NOT targeting placeholder cards
- drawing.pocketPicks.filter(
- (p) => p.targetType !== CHART_PLACEHOLDER,
- ).length;
-
- // insert at head of list, behind other picks/placeholders
- charts.splice(frontLockedCardCount, 0, targetChart);
- }
- set({
- charts,
- });
- }
-
- const existingIndex = arr.findIndex((b) => b.chartId === chartId);
- if (existingIndex >= 0) {
- arr.splice(existingIndex, 1);
- } else {
- arr.push({
- player,
- pick: newChart!,
- chartId,
- targetType: targetChart.type,
- });
- }
- set({
- [key]: arr,
- });
- },
- serializeSyncFields() {
- return Object.entries(get()).reduce((ret: Partial, [k, v]) => {
- if (typeof v === "function") {
- return ret;
- }
- if (k.startsWith("__")) {
- return ret;
- }
- ret[k as keyof Drawing] = v;
- return ret;
- }, {}) as Drawing;
- },
- setWinner(chartId, player) {
- const arr = get().winners.slice();
- const existingIndex = arr.findIndex((b) => b.chartId === chartId);
- if (existingIndex >= 0) {
- arr.splice(existingIndex, 1);
- }
- if (player) {
- arr.push({ player, chartId });
- }
- set({
- winners: arr,
- });
- },
- }),
- (p) => p.initialDrawing.id,
- { initialDrawing: stubDrawing },
-);
-
-export { useDrawing, DrawingProvider, allDrawingStores, useDrawingStore };
+export { useDrawing, DrawingProvider };
diff --git a/src/drawing-list.tsx b/src/drawing-list.tsx
index 12bc132dd..de1afee4c 100644
--- a/src/drawing-list.tsx
+++ b/src/drawing-list.tsx
@@ -1,50 +1,29 @@
-import {
- Suspense,
- lazy,
- memo,
- useDeferredValue,
- useEffect,
- useState,
-} from "react";
+import { Suspense, lazy, memo, useDeferredValue } from "react";
import styles from "./drawing-list.css";
-import { useDrawState } from "./draw-state";
-import { useConfigState } from "./config-state";
-import { Callout, NonIdealState, Spinner } from "@blueprintjs/core";
+import { Callout, NonIdealState } from "@blueprintjs/core";
import { Import } from "@blueprintjs/icons";
import logo from "./assets/ddr-tools-256.png";
-import { ErrorBoundary } from "react-error-boundary";
-import { ErrorFallback } from "./utils/error-fallback";
+import { useAppState } from "./state/store";
+import { drawingsSlice } from "./state/drawings.slice";
+import { DelayedSpinner } from "./common-components/delayed-spinner";
-const EligibleChartsList = lazy(() => import("./eligible-charts"));
const DrawnSet = lazy(() => import("./drawn-set"));
const ScrollableDrawings = memo(() => {
- const drawings = useDeferredValue(useDrawState((s) => s.drawings));
+ const drawingIds = useDeferredValue(useAppState((s) => s.drawings.ids));
return (
-
- {drawings.map((d) => (
-
- ))}
+
+ {drawingIds
+ .map((did) => )
+ .reverse()}
);
});
export function DrawingList() {
const hasDrawings = useDeferredValue(
- useDrawState((s) => !!s.drawings.length),
+ useAppState(drawingsSlice.selectors.haveDrawings),
);
- const showEligible = useDeferredValue(
- useConfigState((cfg) => cfg.showEligibleCharts),
- );
- if (showEligible) {
- return (
-
}>
-
}>
-
-
-
- );
- }
if (!hasDrawings) {
return (
@@ -68,19 +47,3 @@ export function DrawingList() {
);
}
-
-function DelayedSpinner(props: { timeout?: number }) {
- const [show, updateShow] = useState(false);
- useEffect(() => {
- if (show) return;
-
- const timeout = setTimeout(() => {
- updateShow(true);
- }, props.timeout || 250);
- return () => clearTimeout(timeout);
- }, [props.timeout, show]);
- if (show) {
- return
;
- }
- return null;
-}
diff --git a/src/drawn-set.css b/src/drawn-set.css
index 49b259ea2..510e083e8 100644
--- a/src/drawn-set.css
+++ b/src/drawn-set.css
@@ -19,6 +19,11 @@ body:global(.bp5-dark) {
position: relative;
}
+.drawing[data-focused] {
+ outline: 2px inset red;
+ outline-offset: -2px;
+}
+
body:global(.obs-layer) .drawing {
background-image: none !important;
}
diff --git a/src/drawn-set.tsx b/src/drawn-set.tsx
index 36b553056..5f31158cd 100644
--- a/src/drawn-set.tsx
+++ b/src/drawn-set.tsx
@@ -2,13 +2,15 @@ import { memo, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { SongCard } from "./song-card";
import styles from "./drawn-set.css";
-import { Drawing } from "./models/Drawing";
import { SetLabels } from "./tournament-mode/drawing-labels";
import { DrawingProvider, useDrawing } from "./drawing-context";
import { DrawingActions } from "./tournament-mode/drawing-actions";
-import { SyncWithPeers } from "./tournament-mode/sync-with-peers";
-import { useConfigState } from "./config-state";
import { ErrorFallback } from "./utils/error-fallback";
+import { useAtomValue } from "jotai";
+import { showPlayerAndRoundLabels } from "./config-state";
+import { EligibleChart } from "./models/Drawing";
+import { ConfigContextProvider } from "./state/hooks";
+import { useAppState } from "./state/store";
const HUE_STEP = (255 / 8) * 3;
let hue = Math.floor(Math.random() * 255);
@@ -19,7 +21,21 @@ function getRandomGradiant() {
}
interface Props {
- drawing: Drawing;
+ drawingId: string;
+}
+
+export function ChartsOnly({ drawingId }: Props) {
+ const configId = useAppState((s) => s.drawings.entities[drawingId]?.configId);
+ if (!configId) {
+ return null;
+ }
+ return (
+
+
+
+
+
+ );
}
function ChartList() {
@@ -33,18 +49,22 @@ function ChartList() {
);
}
+export function RawChartList(props: { charts: Array
}) {
+ return (
+
+ {props.charts.map((c, idx) => (
+
+ ))}
+
+ );
+}
+
function ChartFromContext({ chartId }: { chartId: string }) {
const chart = useDrawing((d) => d.charts.find((c) => c.id === chartId));
- const veto = useDrawing((d) => d.bans.find((b) => b.chartId === chartId));
- const protect = useDrawing((d) =>
- d.protects.find((b) => b.chartId === chartId),
- );
- const pocketPick = useDrawing((d) =>
- d.pocketPicks.find((b) => b.chartId === chartId),
- );
- const winner = useDrawing((d) =>
- d.winners.find((b) => b.chartId === chartId),
- );
+ const veto = useDrawing((d) => d.bans[chartId]);
+ const protect = useDrawing((d) => d.protects[chartId]);
+ const pocketPick = useDrawing((d) => d.pocketPicks[chartId]);
+ const winner = useDrawing((d) => d.winners[chartId]);
if (!chart) {
return null;
}
@@ -54,7 +74,7 @@ function ChartFromContext({ chartId }: { chartId: string }) {
protectedBy={protect?.player}
replacedBy={pocketPick?.player}
replacedWith={pocketPick?.pick}
- winner={winner?.player}
+ winner={winner}
chart={chart}
actionsEnabled
/>
@@ -62,48 +82,54 @@ function ChartFromContext({ chartId }: { chartId: string }) {
}
function TournamentModeSpacer() {
- const showLabels = useConfigState((s) => s.showPlayerAndRoundLabels);
+ const showLabels = useAtomValue(showPlayerAndRoundLabels);
if (showLabels) {
return null;
}
return ;
}
-const DrawnSet = memo(function DrawnSet({ drawing }) {
+const DrawnSet = memo(function DrawnSet({ drawingId }) {
const [backgroundImage] = useState(getRandomGradiant());
+ const configId = useAppState((s) => s.drawings.entities[drawingId]?.configId);
+ if (!configId) {
+ return null;
+ }
return (
-
-
+
+
+
+
+ }
+ >
-
-
- }
- >
-
-
-
+
+
);
});
diff --git a/src/drop-handler.tsx b/src/drop-handler.tsx
index 4a0696c0c..8327c6a9f 100644
--- a/src/drop-handler.tsx
+++ b/src/drop-handler.tsx
@@ -8,11 +8,12 @@ import {
} from "@blueprintjs/core";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { PackWithSongs } from "simfile-parser/browser";
-import { useDrawState } from "./draw-state";
import { getDataFileFromPack } from "./utils/itg-import";
import { pause } from "./utils/pause";
import { convertErrorToString } from "./utils/error-to-string";
import { Import } from "@blueprintjs/icons";
+import { useSetAtom } from "jotai";
+import { customDataCache } from "./state/game-data.atoms";
function loadParserModule() {
return import("simfile-parser/browser");
@@ -116,7 +117,7 @@ function useDataParsing(
function ConfirmPackDialog({ droppedFolder, onClose, onSave }: DialogProps) {
const [tiered, setTiered] = useState(false);
const [saving, setSaving] = useState(false);
- const loadGameData = useDrawState((s) => s.addImportedData);
+ const setCustomData = useSetAtom(customDataCache);
const { parsedPack, parseError } = useDataParsing(droppedFolder, setTiered);
const derivedData = useMemo(() => {
@@ -131,12 +132,17 @@ function ConfirmPackDialog({ droppedFolder, onClose, onSave }: DialogProps) {
return;
}
setSaving(true);
- loadGameData(parsedPack.name, derivedData);
+ setCustomData((prev) => {
+ return {
+ ...prev,
+ [parsedPack.name]: derivedData,
+ };
+ });
pause(500).then(() => {
setSaving(false);
onSave();
});
- }, [parsedPack, derivedData, loadGameData, onSave]);
+ }, [parsedPack, derivedData, setCustomData, onSave]);
const maybeSkeleton = derivedData ? "" : Classes.SKELETON;
diff --git a/src/eligible-charts/filter.tsx b/src/eligible-charts/filter.tsx
index ca1a2ad5e..e34e666cd 100644
--- a/src/eligible-charts/filter.tsx
+++ b/src/eligible-charts/filter.tsx
@@ -1,6 +1,6 @@
import { HTMLSelect } from "@blueprintjs/core";
import { atom, useAtom } from "jotai";
-import { useConfigState } from "../config-state";
+import { useConfigState } from "../state/hooks";
import { useIntl } from "../hooks/useIntl";
export const currentTabAtom = atom("all");
diff --git a/src/eligible-charts/histogram.tsx b/src/eligible-charts/histogram.tsx
index df5dcc0be..360942930 100644
--- a/src/eligible-charts/histogram.tsx
+++ b/src/eligible-charts/histogram.tsx
@@ -9,30 +9,29 @@ import {
} from "victory";
import { useMemo } from "react";
import { CountingSet } from "../utils/counting-set";
-import { useDrawState } from "../draw-state";
-import { useIntl } from "../hooks/useIntl";
import {
chartLevelOrTier,
getAvailableLevels,
- getDiffClass,
- getMetaString,
+ useGetDiffClass,
+ useGetMetaString,
} from "../game-data-utils";
import { Theme, useTheme } from "../theme-toggle";
import { useIsNarrow } from "../hooks/useMediaQuery";
-import { useConfigState } from "../config-state";
+import { useConfigState, useGameData } from "../state/hooks";
interface Props {
charts: EligibleChart[];
}
export function DiffHistogram({ charts }: Props) {
- const { t } = useIntl();
const fgColor = useTheme() === Theme.Dark ? "white" : undefined;
const isNarrow = useIsNarrow();
- const allDiffs = useDrawState((s) => s.gameData?.meta.difficulties);
- const gameData = useDrawState((s) => s.gameData);
+ const gameData = useGameData();
+ const allDiffs = gameData?.meta.difficulties;
const useGranularLevels = useConfigState((s) => s.useGranularLevels);
const availableLevels = getAvailableLevels(gameData, useGranularLevels);
+ const getDiffClass = useGetDiffClass();
+ const getMetaString = useGetMetaString();
function formatLabel(idx: number) {
const n = availableLevels[idx];
if (!n) return "";
@@ -56,16 +55,16 @@ export function DiffHistogram({ charts }: Props) {
}
const orderedLevels = Array.from(allLevels.values()).sort((a, b) => a - b);
const difficulties = (allDiffs || [])
- .filter((d) => !!countByClassAndLvl[getDiffClass(t, d.key)])
+ .filter((d) => !!countByClassAndLvl[getDiffClass(d.key)])
.reverse();
const dataPerDiff = difficulties.map((diff) => ({
color: diff.color,
key: diff.key,
- label: getMetaString(t, diff.key),
+ label: getMetaString(diff.key),
data: orderedLevels.map((lvl) => ({
xPlacement: availableLevels.indexOf(lvl),
level: lvl,
- count: countByClassAndLvl[getDiffClass(t, diff.key)].get(lvl) || 0,
+ count: countByClassAndLvl[getDiffClass(diff.key)].get(lvl) || 0,
})),
}));
return [
@@ -76,7 +75,14 @@ export function DiffHistogram({ charts }: Props) {
.sort((a, b) => a[0] - b[0])
.map(([, count]) => count),
];
- }, [allDiffs, charts, useGranularLevels, t, availableLevels]);
+ }, [
+ allDiffs,
+ charts,
+ useGranularLevels,
+ getDiffClass,
+ getMetaString,
+ availableLevels,
+ ]);
return (
s.gameData);
+export default function EligibleChartsView() {
+ const [configId, setConfigId] = useState(null);
+ const selector = (
+
+ );
+
+ if (!configId) {
+ return selector;
+ }
+
+ return (
+ <>
+ {selector}
+
+
+
+ >
+ );
+}
+
+export function EligibleChartsList() {
+ const gameData = useGameData();
const [currentTab] = useDeferredValue(useAtom(currentTabAtom));
const configState = useDeferredValue(useConfigState());
const isNarrow = useIsNarrow();
@@ -67,7 +91,7 @@ export default function EligibleChartsList() {
{charts.length} eligible charts from {songs.size} songs (of{" "}
{gameData.songs.length} total)
- {configState.flags.size > 0 && !isNarrow && (
+ {configState.flags.length > 0 && !isNarrow && (
diff --git a/src/game-data-utils.tsx b/src/game-data-utils.tsx
index bed72b1eb..2c1fdded4 100644
--- a/src/game-data-utils.tsx
+++ b/src/game-data-utils.tsx
@@ -1,18 +1,30 @@
+import { useCallback } from "react";
import { useIntl } from "./hooks/useIntl";
import { EligibleChart } from "./models/Drawing";
import { Chart, GameData, I18NDict } from "./models/SongData";
+import { useConfigState } from "./state/hooks";
-export function getMetaString(t: (key: string) => string, key: string) {
- return t("meta." + key);
+export function useGetMetaString() {
+ const { t } = useIntl();
+ const gameKey = useConfigState((c) => c.gameKey);
+ return useCallback(
+ (key: string) => t(`game.${gameKey}.${key}`),
+ [gameKey, t],
+ );
}
export function MetaString({ key }: { key: string }) {
- const { t } = useIntl();
- return <>{getMetaString(t, key)}>;
+ const getMetaString = useGetMetaString();
+ return <>{getMetaString(key)}>;
}
-export function getDiffClass(t: (key: string) => string, diffClassKey: string) {
- return t("meta.$abbr." + diffClassKey);
+export function useGetDiffClass() {
+ const { t } = useIntl();
+ const gameKey = useConfigState((c) => c.gameKey);
+ return useCallback(
+ (diffClassKey: string) => t(`game.${gameKey}.$abbr.${diffClassKey}`),
+ [gameKey, t],
+ );
}
interface AbbrProps {
@@ -20,8 +32,8 @@ interface AbbrProps {
}
export function AbbrDifficulty({ diffClass }: AbbrProps) {
- const { t } = useIntl();
- return <>{getDiffClass(t, diffClass)}>;
+ const getDiffClass = useGetDiffClass();
+ return <>{getDiffClass(diffClass)}>;
}
/**
diff --git a/src/header.tsx b/src/header.tsx
index 290758428..e496147f2 100644
--- a/src/header.tsx
+++ b/src/header.tsx
@@ -8,19 +8,23 @@ import {
Popover,
} from "@blueprintjs/core";
import { Trash, InfoSign, Menu as MenuIcon, Help } from "@blueprintjs/icons";
-import { useState } from "react";
+import { useCallback, useState } from "react";
import { About } from "./about";
import { HeaderControls } from "./controls";
import { useIntl } from "./hooks/useIntl";
import { LastUpdate } from "./last-update";
import { ThemeToggle } from "./theme-toggle";
-import { DataLoadingSpinner, VersionSelect } from "./version-select";
-import { useDrawState } from "./draw-state";
+import { useAppDispatch, useAppState } from "./state/store";
+import { drawingsSlice } from "./state/drawings.slice";
export function Header() {
const [aboutOpen, setAboutOpen] = useState(false);
- const clearDrawings = useDrawState((d) => d.clearDrawings);
- const haveDrawings = useDrawState((d) => !!d.drawings.length);
+ const dispatch = useAppDispatch();
+ const clearDrawings = useCallback(
+ () => dispatch(drawingsSlice.actions.clearDrawings()),
+ [dispatch],
+ );
+ const haveDrawings = useAppState(drawingsSlice.selectors.haveDrawings);
const { t } = useIntl();
const menu = (
@@ -63,8 +67,12 @@ export function Header() {
} data-umami-event="hamburger-menu-open" />
-
-
+
+ Event Mode{" "}
+
+ Alpha Preview
+
+
diff --git a/src/hooks/useDataSets.ts b/src/hooks/useDataSets.ts
index ba87d4d2d..621ddb7d2 100644
--- a/src/hooks/useDataSets.ts
+++ b/src/hooks/useDataSets.ts
@@ -1,17 +1,15 @@
import { useMemo } from "react";
-import { useDrawState } from "../draw-state";
import { availableGameData } from "../utils";
+import { customDataCache } from "../state/game-data.atoms";
+import { useAtomValue } from "jotai";
export function useDataSets() {
- const dataSetName = useDrawState((s) => s.dataSetName);
- const loadGameData = useDrawState((s) => s.loadGameData);
- const dataIsLoaded = useDrawState((s) => !!s.gameData);
- const importedData = useDrawState((s) => s.importedData);
+ const importedData = useAtomValue(customDataCache);
const available = useMemo(() => {
return [
...availableGameData,
- ...Array.from(importedData.values()).map((d) => ({
+ ...Object.values(importedData).map((d) => ({
name: d.i18n.en.name as string,
display: d.i18n.en.name as string,
parent: d.meta.menuParent || "imported",
@@ -19,12 +17,7 @@ export function useDataSets() {
];
}, [importedData]);
- const current = available.find((s) => s.name === dataSetName) || available[0];
-
return {
available,
- current,
- loadData: loadGameData,
- dataIsLoaded,
};
}
diff --git a/src/hooks/useFuzzySearch.ts b/src/hooks/useFuzzySearch.ts
new file mode 100644
index 000000000..4d79d6dd0
--- /dev/null
+++ b/src/hooks/useFuzzySearch.ts
@@ -0,0 +1,14 @@
+import FuzzySearch from "fuzzy-search";
+import { useGameData } from "../state/hooks";
+
+export function useFuzzySearch() {
+ const gameData = useGameData();
+ if (!gameData) return null;
+ return new FuzzySearch(
+ gameData.songs,
+ ["name", "name_translation", "search_hint", "artist", "artist_translation"],
+ {
+ sort: true,
+ },
+ );
+}
diff --git a/src/intl-provider.tsx b/src/intl-provider.tsx
index 58b1623ad..656d52565 100644
--- a/src/intl-provider.tsx
+++ b/src/intl-provider.tsx
@@ -1,43 +1,47 @@
+import { useAtomValue } from "jotai";
+import { ReactNode, useMemo } from "react";
import { IntlProvider as UpstreamProvider } from "react-intl";
-import { PropsWithChildren, useMemo } from "react";
-import { flattenedKeys } from "./utils";
+import translations from "./assets/i18n.json";
import { I18NDict } from "./models/SongData";
+import { detectedLanguage, flattenedKeys } from "./utils";
+import { stockDataCache } from "./state/game-data.atoms";
const FALLBACK_LOCALE = "en";
-interface Props {
- locale: string;
- translations: Record;
- mergeTranslations?: Record;
-}
+const typedTranslations = translations as Record;
+
+export function IntlProvider({ children }: { children: ReactNode }) {
+ const allLoadedData = useAtomValue(stockDataCache);
-export function IntlProvider({
- locale,
- translations,
- mergeTranslations,
- children,
-}: PropsWithChildren) {
const messages = useMemo(() => {
const ret: Record = {};
- for (const [k, v] of flattenedKeys(translations[FALLBACK_LOCALE])) {
+ for (const [k, v] of flattenedKeys(typedTranslations[FALLBACK_LOCALE])) {
ret[k] = v;
}
- for (const [k, v] of flattenedKeys(translations[locale])) {
+ for (const [k, v] of flattenedKeys(typedTranslations[detectedLanguage])) {
ret[k] = v;
}
- if (mergeTranslations) {
- for (const [k, v] of flattenedKeys(mergeTranslations[FALLBACK_LOCALE])) {
- ret[`meta.${k}`] = v;
- }
- for (const [k, v] of flattenedKeys(mergeTranslations[locale])) {
- ret[`meta.${k}`] = v;
+ for (const [gameKey, data] of Object.entries(allLoadedData)) {
+ const gameSpecificTranslations = data.i18n;
+ if (gameSpecificTranslations) {
+ const keyPrefix = `game.${gameKey}.`;
+ for (const [k, v] of flattenedKeys(
+ gameSpecificTranslations[FALLBACK_LOCALE],
+ )) {
+ ret[keyPrefix + k] = v;
+ }
+ for (const [k, v] of flattenedKeys(
+ gameSpecificTranslations[detectedLanguage],
+ )) {
+ ret[keyPrefix + k] = v;
+ }
}
}
return ret;
- }, [translations, mergeTranslations, locale]);
+ }, [allLoadedData]);
return (
-
+
{children}
);
diff --git a/src/last-update.tsx b/src/last-update.tsx
index ddd9e4a86..d17a0c0c2 100644
--- a/src/last-update.tsx
+++ b/src/last-update.tsx
@@ -1,31 +1,37 @@
-import { Classes, Text } from "@blueprintjs/core";
-import cn from "classnames";
-import { FormattedMessage } from "react-intl";
-import { shallow } from "zustand/shallow";
-import { useDrawState } from "./draw-state";
-import { detectedLanguage } from "./utils";
+// import { Classes, Text } from "@blueprintjs/core";
+// import cn from "classnames";
+// import { FormattedMessage } from "react-intl";
+// import { detectedLanguage } from "./utils";
+// import { useAppState } from "./state/store";
+// import { useStockGameData } from "./state/game-data.atoms";
export function LastUpdate() {
- const [dataSetName, gameData] = useDrawState(
- (s) => [s.dataSetName, s.gameData],
- shallow,
- );
- if (!gameData) {
- return null;
- }
- const lastUpdate = new Date(gameData.meta.lastUpdated);
- return (
-
-
-
- );
+ return null; // for now it doesn't make sense because a config can't be selected globally
+ // const dataSetName = useAppState((s) => s.config.current);
+ // if (!dataSetName) {
+ // return null;
+ // }
+ // return ;
}
+
+// function LastUpdateForGame(props: { game: string }) {
+// const gameData = useStockGameData(props.game);
+// if (!gameData) {
+// return null;
+// }
+// const lastUpdate = new Date(gameData.meta.lastUpdated);
+// return (
+//
+//
+//
+// );
+// }
diff --git a/src/main-view.css b/src/main-view.css
new file mode 100644
index 000000000..e5399647f
--- /dev/null
+++ b/src/main-view.css
@@ -0,0 +1,18 @@
+.mainView {
+ flex: 1 1 0px;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+}
+
+/* the tabs themselves */
+.mainView > div:first-child {
+ flex: 0 0 auto;
+}
+
+/* all the full page tab contents */
+.mainView > div:not(:first-child) {
+ flex: 1 1 auto;
+ overflow-y: auto;
+}
diff --git a/src/main-view.tsx b/src/main-view.tsx
new file mode 100644
index 000000000..5620f0533
--- /dev/null
+++ b/src/main-view.tsx
@@ -0,0 +1,46 @@
+import { Tabs, Tab } from "@blueprintjs/core";
+import { PlayerNamesControls } from "./controls/player-names";
+import { DrawingList } from "./drawing-list";
+import { atom, useAtom } from "jotai";
+import styles from "./main-view.css";
+import { lazy, Suspense } from "react";
+import { ErrorBoundary } from "react-error-boundary";
+import { ErrorFallback } from "./utils/error-fallback";
+import { DelayedSpinner } from "./common-components/delayed-spinner";
+
+export type MainTabId = "drawings" | "players" | "sets";
+export const mainTabAtom = atom("drawings");
+
+const EligibleChartsList = lazy(() => import("./eligible-charts"));
+
+export function MainView() {
+ const [currentTab, setCurrentTab] = useAtom(mainTabAtom);
+ return (
+ setCurrentTab(newTabId)}
+ >
+ }>
+ Drawings
+
+ }>
+ }>
+
+
+
+ }
+ >
+ Eligible Charts
+
+ }>
+ Start.gg Sync
+
+
+ );
+}
diff --git a/src/matches.tsx b/src/matches.tsx
new file mode 100644
index 000000000..16af2c099
--- /dev/null
+++ b/src/matches.tsx
@@ -0,0 +1,206 @@
+import { Button, Card, Classes, Spinner, Text } from "@blueprintjs/core";
+import { useStartggMatches, useStartggPhases } from "./startgg-gql";
+import { createAppSelector, useAppState } from "./state/store";
+import { inferShortname } from "./controls/player-names";
+import { Refresh } from "@blueprintjs/icons";
+
+export interface PickedMatch {
+ title: string;
+ players: Array<{ id: string; name: string }>;
+ id: string;
+ subtype: "versus" | "gauntlet";
+}
+
+const associatedMatchIds = createAppSelector(
+ [(s) => s.drawings.entities],
+ (entities) => {
+ return Object.values(entities).flatMap((drawing) => {
+ if (drawing.meta.type === "startgg") return drawing.meta.id;
+ return [];
+ });
+ },
+);
+
+export function MatchPicker(props: { onPickMatch?(match: PickedMatch): void }) {
+ const [resp, refetch] = useStartggMatches();
+ const existingMatches = useAppState(associatedMatchIds);
+ const event = resp.data?.event;
+ const matches = event?.sets?.nodes;
+ const reloadButton = (
+ : }
+ onClick={() => refetch({ requestPolicy: "network-only" })}
+ />
+ );
+ if (!event) {
+ return (
+
+ {reloadButton} startgg didn't have an event for the current slug
+
+ );
+ }
+ if (!matches) {
+ if (resp.fetching) {
+ return (
+
+ {reloadButton}
+
+ loading content for a match
+
+
+ loading content for a match
+
+
+ loading content for a match
+
+
+ );
+ } else {
+ return {reloadButton} startgg didn't respond matches
;
+ }
+ }
+ if (!matches.length)
+ return {reloadButton} no un-settled matches found
;
+
+ return (
+
+ {reloadButton}
+ {matches
+ .filter((m) => !!m)
+ .map((match) => {
+ const title = match.fullRoundText || "???";
+ const p1 = inferShortname(match.slots![0]?.entrant?.name);
+ const p2 = inferShortname(match.slots![1]?.entrant?.name);
+ const matchUsed = existingMatches.includes(match.id!);
+ return (
+
+ props.onPickMatch?.({
+ title,
+ players: match.slots!.map((slot) => ({
+ id: slot!.entrant!.id!,
+ name: inferShortname(slot!.entrant!.name)!,
+ })),
+ id: match.id!,
+ subtype: "versus",
+ })
+ }
+ >
+
+ {title} - {p1 || TBD} vs{" "}
+ {p2 || TBD}
+
+
+ );
+ })}
+
+ );
+}
+
+export function GauntletPicker(props: {
+ onPickMatch?(match: PickedMatch): void;
+}) {
+ const [resp, refetch] = useStartggPhases();
+ const existingMatches = useAppState(associatedMatchIds);
+ const event = resp.data?.event;
+ const phases = event?.phases?.filter(
+ (p) => p?.bracketType === "CUSTOM_SCHEDULE",
+ );
+ const reloadButton = (
+ : }
+ onClick={() => refetch({ requestPolicy: "network-only" })}
+ />
+ );
+ if (!event) {
+ return (
+
+ {reloadButton} startgg didn't have an event for the current slug
+
+ );
+ }
+ if (!phases) {
+ if (resp.fetching) {
+ return (
+
+ {reloadButton}
+
+ loading content for a gauntlet
+
+
+ loading content for a gauntlet
+
+
+ loading content for a gauntlet
+
+
+ );
+ } else {
+ return {reloadButton} startgg didn't respond with phases
;
+ }
+ }
+ if (!phases.length)
+ return {reloadButton} no phases with custom schedule found
;
+
+ return (
+
+ {reloadButton}
+ {phases
+ .filter((p) => !!p)
+ .map((phase) => {
+ const title = phase.name || "???";
+ const entrants =
+ phase.seeds?.nodes?.flatMap((seed) => {
+ if (
+ !seed ||
+ !seed.entrant ||
+ !seed.entrant.name ||
+ !seed.entrant.id
+ ) {
+ return [];
+ }
+ return {
+ name: inferShortname(seed.entrant.name),
+ id: seed.entrant.id,
+ };
+ }) || [];
+ const matchUsed = existingMatches.includes(phase.id!);
+ return (
+
+ props.onPickMatch?.({
+ title,
+ players: entrants,
+ id: phase.id!,
+ subtype: "gauntlet",
+ })
+ }
+ >
+
+ {title} (
+ {entrants.map((e) => e.name).join(", ")})
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/models/Drawing.ts b/src/models/Drawing.ts
index 728219326..71966e644 100644
--- a/src/models/Drawing.ts
+++ b/src/models/Drawing.ts
@@ -1,4 +1,3 @@
-import { DataConnection } from "peerjs";
import { Song } from "./SongData";
export interface EligibleChart {
@@ -38,19 +37,84 @@ export interface PlayerActionOnChart {
export interface PocketPick extends PlayerActionOnChart {
pick: EligibleChart;
- targetType: typeof CHART_PLACEHOLDER | typeof CHART_DRAWN;
}
-export interface Drawing {
+interface StartggMeta {
+ type: "startgg";
+ title: string;
+ entrants: Array<{ id: string; name: string }>;
+ /** first index is entrant ID, second index is the drawn chart ID */
+ scoresByEntrant?: Record>;
+}
+
+export interface StartggVersusMeta extends StartggMeta {
+ subtype: "versus";
+ /** id of the set */
+ id: string;
+}
+
+export interface StartggGauntletMeta extends StartggMeta {
+ subtype: "gauntlet";
+ /** id of the phase */
id: string;
- title?: string;
+}
+
+export interface SimpleMeta {
+ type: "simple";
+ title: string;
+ /** plain player names */
players: string[];
+}
+
+export function playerCount(meta: Drawing["meta"]) {
+ switch (meta.type) {
+ case "simple":
+ return meta.players.length;
+ case "startgg":
+ return meta.entrants.length;
+ }
+}
+
+export function getAllPlayers(d: Pick) {
+ const ret = [] as string[];
+ for (let i = 1; i <= playerCount(d.meta); i++) {
+ ret.push(playerNameByDisplayPos(d, i));
+ }
+ return ret;
+}
+
+export function playerNameByDisplayPos(
+ d: Pick,
+ pos: number,
+) {
+ const playerIndex = d.playerDisplayOrder[pos - 1];
+ return playerNameByIndex(d.meta, playerIndex);
+}
+
+export function playerNameByIndex(
+ meta: Drawing["meta"],
+ idx: number,
+ fallback = `P${idx + 1}`,
+) {
+ switch (meta.type) {
+ case "simple":
+ return meta.players[idx] || fallback;
+ case "startgg":
+ return meta.entrants[idx].name || fallback;
+ }
+}
+
+export interface Drawing {
+ id: string;
+ configId: string;
+ meta: SimpleMeta | StartggVersusMeta | StartggGauntletMeta;
+ /** index of items of the players array, in the order they should be displayed */
+ playerDisplayOrder: number[];
+ /** map of song ID to player index */
+ winners: Record;
charts: Array;
- bans: Array;
- protects: Array;
- winners: Array;
- pocketPicks: Array;
+ bans: Record;
+ protects: Record;
+ pocketPicks: Record;
priorityPlayer?: number;
- /** __ prefix avoids serializing this field during sync */
- __syncPeer?: DataConnection;
}
diff --git a/src/obs-sources/cards.tsx b/src/obs-sources/cards.tsx
new file mode 100644
index 000000000..9a930e0ce
--- /dev/null
+++ b/src/obs-sources/cards.tsx
@@ -0,0 +1,12 @@
+import { useParams } from "react-router-dom";
+import { ChartsOnly } from "../drawn-set";
+import { useAppState } from "../state/store";
+
+export function CabCards() {
+ const params = useParams<"roomName" | "cabId">();
+ const drawingId = useAppState((s) => s.event.cabs[params.cabId!].activeMatch);
+ if (!drawingId) {
+ return null;
+ }
+ return ;
+}
diff --git a/src/obs-sources/text.tsx b/src/obs-sources/text.tsx
new file mode 100644
index 000000000..96824c9bd
--- /dev/null
+++ b/src/obs-sources/text.tsx
@@ -0,0 +1,54 @@
+import { useParams } from "react-router-dom";
+import { drawingSelectors } from "../state/drawings.slice";
+import { useAppState } from "../state/store";
+import { getAllPlayers, playerNameByIndex } from "../models/Drawing";
+
+export function CabTitle() {
+ const params = useParams<"roomName" | "cabId">();
+ const text = useAppState((s) => {
+ const drawingId = s.event.cabs[params.cabId!].activeMatch;
+ if (!drawingId) return null;
+ const drawing = drawingSelectors.selectById(s, drawingId);
+ if (!drawing) return null;
+ return drawing.meta.title;
+ });
+ return {text}
;
+}
+
+export function CabPlayers() {
+ const params = useParams<"roomName" | "cabId">();
+ const text = useAppState((s) => {
+ const drawingId = s.event.cabs[params.cabId!].activeMatch;
+ if (!drawingId) return null;
+ const drawing = drawingSelectors.selectById(s, drawingId);
+ if (!drawing) return null;
+ return getAllPlayers(drawing).join(", ");
+ });
+ return {text}
;
+}
+
+export function CabPlayer(props: { p: number }) {
+ const params = useParams<"roomName" | "cabId">();
+ const text = useAppState((s) => {
+ const drawingId = s.event.cabs[params.cabId!].activeMatch;
+ if (!drawingId) return null;
+ const drawing = drawingSelectors.selectById(s, drawingId);
+ if (!drawing) return null;
+ const playerIndex = drawing.playerDisplayOrder[props.p - 1];
+ const name = playerNameByIndex(drawing.meta, playerIndex, "");
+ const hideWins =
+ drawing.meta.type === "startgg" && drawing.meta.subtype === "gauntlet";
+ if (hideWins) {
+ return name;
+ }
+ const score = Object.values(drawing.winners).reduce(
+ (prev, curr) => {
+ if (curr === playerIndex) return prev + 1;
+ return prev;
+ },
+ 0,
+ );
+ return `${name} (${score})`;
+ });
+ return {text}
;
+}
diff --git a/src/party/README.md b/src/party/README.md
new file mode 100644
index 000000000..113b8b895
--- /dev/null
+++ b/src/party/README.md
@@ -0,0 +1 @@
+This is where the partykit server is implemented. See: https://docs.partykit.io/reference/partyserver-api/
diff --git a/src/party/client.tsx b/src/party/client.tsx
new file mode 100644
index 000000000..ebef75964
--- /dev/null
+++ b/src/party/client.tsx
@@ -0,0 +1,83 @@
+import usePartySocket from "partysocket/react";
+import type { Broadcast, ReduxAction } from "./types";
+import { useAppDispatch } from "../state/store";
+import { receivePartyState } from "../state/central";
+import { startAppListening } from "../state/listener-middleware";
+import React, { useEffect, useState } from "react";
+import { Card, NonIdealState, Spinner } from "@blueprintjs/core";
+import { DelayRender } from "../utils/delay-render";
+
+export function PartySocketManager(props: {
+ roomName?: string;
+ children: React.ReactNode;
+}) {
+ const dispatch = useAppDispatch();
+ // TODO move this state to redux???
+ const [ready, setReady] = useState(false);
+ const socket = usePartySocket({
+ room: props.roomName,
+ host:
+ process.env.NODE_ENV === "development"
+ ? "localhost:1999"
+ : "ddr-card-draw-party.noahm.partykit.dev",
+ onMessage(evt) {
+ try {
+ const data: Broadcast = JSON.parse(evt.data);
+ switch (data.type) {
+ case "roomstate":
+ dispatch(receivePartyState(data.state));
+ setReady(true);
+ break;
+ case "action":
+ const foreignAction = {
+ ...data.action,
+ meta: { source: "partykit" },
+ };
+ dispatch(foreignAction);
+ break;
+ }
+ } catch (e) {
+ console.warn("failed to handle party socket message", e);
+ }
+ },
+ });
+
+ useEffect(() => {
+ return startAppListening({
+ predicate(action) {
+ // @ts-expect-error i don't know how to type action meta properties yet
+ if (action.meta?.source === "partykit") {
+ return false;
+ }
+
+ if (receivePartyState.match(action)) {
+ return false;
+ }
+
+ return true;
+ },
+ effect(action) {
+ const message: ReduxAction = {
+ type: "action",
+ action,
+ };
+ socket.send(JSON.stringify(message));
+ },
+ });
+ }, [socket]);
+
+ if (!ready) {
+ return (
+
+
+
+ } title="Connecting..." />
+
+
+
+ );
+ }
+ return props.children;
+}
diff --git a/src/party/database.types.ts b/src/party/database.types.ts
new file mode 100644
index 000000000..2ebf7fb62
--- /dev/null
+++ b/src/party/database.types.ts
@@ -0,0 +1,131 @@
+/* generated with `npx supabase gen types typescript --project-id tawrleybdkxqrcnycmaa > src/party/database.types.ts` */
+
+export type Json =
+ | string
+ | number
+ | boolean
+ | null
+ | { [key: string]: Json | undefined }
+ | Json[];
+
+export type Database = {
+ public: {
+ Tables: {
+ event_state: {
+ Row: {
+ created_at: string;
+ id: string;
+ state: Json | null;
+ updated_at: string;
+ };
+ Insert: {
+ created_at?: string;
+ id: string;
+ state?: Json | null;
+ updated_at?: string;
+ };
+ Update: {
+ created_at?: string;
+ id?: string;
+ state?: Json | null;
+ updated_at?: string;
+ };
+ Relationships: [];
+ };
+ };
+ Views: {
+ [_ in never]: never;
+ };
+ Functions: {
+ [_ in never]: never;
+ };
+ Enums: {
+ [_ in never]: never;
+ };
+ CompositeTypes: {
+ [_ in never]: never;
+ };
+ };
+};
+
+type PublicSchema = Database[Extract];
+
+export type Tables<
+ PublicTableNameOrOptions extends
+ | keyof (PublicSchema["Tables"] & PublicSchema["Views"])
+ | { schema: keyof Database },
+ TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
+ ? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
+ Database[PublicTableNameOrOptions["schema"]]["Views"])
+ : never = never,
+> = PublicTableNameOrOptions extends { schema: keyof Database }
+ ? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
+ Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
+ Row: infer R;
+ }
+ ? R
+ : never
+ : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] &
+ PublicSchema["Views"])
+ ? (PublicSchema["Tables"] &
+ PublicSchema["Views"])[PublicTableNameOrOptions] extends {
+ Row: infer R;
+ }
+ ? R
+ : never
+ : never;
+
+export type TablesInsert<
+ PublicTableNameOrOptions extends
+ | keyof PublicSchema["Tables"]
+ | { schema: keyof Database },
+ TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
+ ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
+ : never = never,
+> = PublicTableNameOrOptions extends { schema: keyof Database }
+ ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
+ Insert: infer I;
+ }
+ ? I
+ : never
+ : PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
+ ? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
+ Insert: infer I;
+ }
+ ? I
+ : never
+ : never;
+
+export type TablesUpdate<
+ PublicTableNameOrOptions extends
+ | keyof PublicSchema["Tables"]
+ | { schema: keyof Database },
+ TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
+ ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
+ : never = never,
+> = PublicTableNameOrOptions extends { schema: keyof Database }
+ ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
+ Update: infer U;
+ }
+ ? U
+ : never
+ : PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
+ ? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
+ Update: infer U;
+ }
+ ? U
+ : never
+ : never;
+
+export type Enums<
+ PublicEnumNameOrOptions extends
+ | keyof PublicSchema["Enums"]
+ | { schema: keyof Database },
+ EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
+ ? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
+ : never = never,
+> = PublicEnumNameOrOptions extends { schema: keyof Database }
+ ? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
+ : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
+ ? PublicSchema["Enums"][PublicEnumNameOrOptions]
+ : never;
diff --git a/src/party/server.ts b/src/party/server.ts
new file mode 100644
index 000000000..3936f256f
--- /dev/null
+++ b/src/party/server.ts
@@ -0,0 +1,115 @@
+import type * as Party from "partykit/server";
+import type { ReduxAction, Roomstate } from "./types";
+import { configureStore } from "@reduxjs/toolkit";
+import { reducer } from "../state/root-reducer";
+import { AppState, type store as appReduxStore } from "../state/store";
+
+import { createClient } from "@supabase/supabase-js";
+import type { Database, Json } from "./database.types";
+
+const supabase = createClient(
+ process.env.SUPABASE_URL as string,
+ process.env.SUPABASE_KEY as string,
+ { auth: { persistSession: false } },
+);
+
+function isAppState(state: unknown): state is AppState {
+ if (state && !Array.isArray(state) && typeof state === "object") {
+ return "config" in state && "drawings" in state;
+ }
+ return false;
+}
+
+export default class Server implements Party.Server {
+ // @ts-expect-error I assign this for sure
+ private store: typeof appReduxStore;
+
+ constructor(readonly room: Party.Room) {
+ console.log("constructor start");
+ }
+
+ async onStart() {
+ let preloadedState: AppState | undefined;
+ try {
+ preloadedState =
+ (await this.getFromStorage()) || (await this.getFromSupabase());
+ } catch {}
+ if (preloadedState) {
+ this.store = configureStore({ reducer, preloadedState });
+ } else {
+ this.store = configureStore({ reducer });
+ }
+ }
+
+ private async getFromSupabase() {
+ const { data, error } = await supabase
+ .from("event_state")
+ .select("state")
+ .eq("id", this.room.id)
+ .maybeSingle();
+ if (error) {
+ throw new Error(error.message);
+ }
+ if (data && isAppState(data.state)) return data.state;
+ }
+
+ private getFromStorage() {
+ return this.room.storage.get("currentState");
+ }
+
+ onRequest(req: Party.Request): Response | Promise {
+ if (req.method === "GET") {
+ return new Response(this.getRoomState());
+ }
+
+ return new Response("Method not allowed", { status: 405 });
+ }
+
+ onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
+ // A websocket just connected!
+ console.log(
+ `Connected:
+ id: ${conn.id}
+ room: ${this.room.id}
+ url: ${new URL(ctx.request.url).pathname}`,
+ );
+
+ // send the initial state to this client
+ conn.send(this.getRoomState());
+ }
+
+ async onMessage(message: string, sender: Party.Connection) {
+ // broadcast it to all the other connections in the room...
+ this.room.broadcast(
+ message,
+ // ...except for the connection it came from
+ [sender.id],
+ );
+
+ const parsed = JSON.parse(message) as ReduxAction;
+ // resolve the new state
+ this.store.dispatch(parsed.action);
+ const nextState = this.store.getState();
+ // persist to partykit storage
+ this.room.storage.put("currentState", nextState);
+ // persist the state to supabase
+ try {
+ await supabase.from("event_state").upsert({
+ id: this.room.id,
+ state: nextState as unknown as Json,
+ updated_at: new Date().toISOString(),
+ });
+ } catch (e) {
+ console.warn("error with upsert", e);
+ }
+ }
+
+ private getRoomState() {
+ return JSON.stringify({
+ type: "roomstate",
+ state: this.store.getState(),
+ });
+ }
+}
+
+Server satisfies Party.Worker;
diff --git a/src/party/types.ts b/src/party/types.ts
new file mode 100644
index 000000000..7aec1910e
--- /dev/null
+++ b/src/party/types.ts
@@ -0,0 +1,15 @@
+import { Action } from "@reduxjs/toolkit";
+import { AppState } from "../state/store";
+
+export interface Roomstate {
+ type: "roomstate";
+ state: AppState;
+}
+
+export interface ReduxAction {
+ type: "action";
+ action: Action;
+}
+
+/** All messages possibly sent by the server to clients */
+export type Broadcast = Roomstate | ReduxAction;
diff --git a/src/song-card/card-label.tsx b/src/song-card/card-label.tsx
index a1c3c05bb..6806646f1 100644
--- a/src/song-card/card-label.tsx
+++ b/src/song-card/card-label.tsx
@@ -3,7 +3,7 @@ import React from "react";
import { Intent, Tag } from "@blueprintjs/core";
import styles from "./card-label.css";
import { Inheritance, BanCircle, Lock, Crown, Draw } from "@blueprintjs/icons";
-import { usePlayerLabel } from "./use-player-label";
+import { usePlayerLabelForIndex } from "./use-player-label";
export enum LabelType {
Protect = 1,
@@ -14,7 +14,7 @@ export enum LabelType {
}
interface Props {
- player: number;
+ playerIdx: number;
type: LabelType;
onRemove?: () => void;
}
@@ -49,8 +49,8 @@ function getIcon(type: LabelType) {
}
}
-export function CardLabel({ player, type, onRemove }: Props) {
- const label = usePlayerLabel(player);
+export function CardLabel({ playerIdx, type, onRemove }: Props) {
+ const label = usePlayerLabelForIndex(playerIdx);
const rootClassname = classNames(styles.cardLabel, {
[styles.winner]: type === LabelType.Winner,
diff --git a/src/song-card/chart-level.tsx b/src/song-card/chart-level.tsx
index 89b556857..ea52f3ada 100644
--- a/src/song-card/chart-level.tsx
+++ b/src/song-card/chart-level.tsx
@@ -1,4 +1,4 @@
-import { useConfigState } from "../config-state";
+import { useConfigState } from "../state/hooks";
import { formatLevel } from "../game-data-utils";
import {
CHART_PLACEHOLDER,
diff --git a/src/song-card/icon-menu.tsx b/src/song-card/icon-menu.tsx
index f673ea922..705d0f386 100644
--- a/src/song-card/icon-menu.tsx
+++ b/src/song-card/icon-menu.tsx
@@ -9,6 +9,7 @@ import {
} from "@blueprintjs/icons";
import { Menu, MenuItem, MenuDivider } from "@blueprintjs/core";
import { useDrawing } from "../drawing-context";
+import { playerNameByIndex } from "../models/Drawing";
interface Props {
onStartPocketPick?: (p: number) => void;
@@ -74,14 +75,17 @@ interface IconRowProps {
}
function PlayerList({ icon, text, onClick }: IconRowProps) {
- const players = useDrawing((d) => d.players);
+ const drawingMeta = useDrawing((d) => d.meta);
+ const players = useDrawing((d) => d.playerDisplayOrder).map(
+ (pIdx) => [playerNameByIndex(drawingMeta, pIdx), pIdx] as const,
+ );
return (