diff --git a/package.json b/package.json index 1792a5057..d6c06194f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "1.0.0", "description": "Tools for music game tournaments and training", "engines": { - "node": "^18.16.0" + "node": "^18.15.0" }, "scripts": { "start": "webpack serve --env dev=true", @@ -45,10 +45,10 @@ "@pmmmwh/react-refresh-webpack-plugin": "^0.5.8", "@types/better-sqlite3": "^7.6.0", "@types/fuzzy-search": "^2.1.0", - "@types/react": "^17.0.34", - "@types/react-dom": "^17.0.11", - "autoprefixer": "^10.4.14", - "babel-loader": "^9.1.2", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "autoprefixer": "^10.4.0", + "babel-loader": "^9.0.0", "better-sqlite3": "^8.0.0", "cacheable-lookup": "^7.0.0", "classnames": "^2.3.1", @@ -72,17 +72,19 @@ "json-schema-to-typescript": "^12.0.0", "jsonschema": "^1.4.1", "mini-css-extract-plugin": "^2.7.6", + "nanoid": "^4.0.0", "node-fetch": "^2.6.7", "normalize.css": "^8.0.1", "p-queue": "^6.0.0", + "peerjs": "^2.0.0-beta.3", "postcss": "^8.4.4", "postcss-loader": "^6.2.1", "prettier": "^2.8.0", "query-string": "^6.13.1", - "react": "^17.0.2", - "react-dom": "^17.0.2", + "react": "^18.0.0", + "react-dom": "^18.0.0", "react-feather": "^2.0.9", - "react-intl": "^6.3.0", + "react-intl": "^6.0.0", "react-refresh": "^0.14.0", "sanitize-filename": "^1.6.3", "simfile-parser": "^0.4.0", @@ -95,14 +97,16 @@ "webpack-sources": "^3.2.1", "xml2js": "^0.5.0", "zip-webpack-plugin": "^4.0.0", - "zustand": "^3.6.4" + "zustand": "^4.1.0" }, "resolutions": { "loader-utils": "^2.0.0", "xml2js": "^0.5.0" }, "dependencies": { + "@reduxjs/toolkit": "^1.9.5", "@vercel/analytics": "^1.0.1", + "react-redux": "^8.0.5", "web-vitals": "^3.3.1" } } diff --git a/src/app.css b/src/app.css index b2e262316..2cbecbc51 100644 --- a/src/app.css +++ b/src/app.css @@ -14,6 +14,10 @@ body:global(.bp4-dark) { background-color: #222; } +:global(.bp4-dark) :global(.bp4-navbar) { + box-shadow: none; +} + .container { display: flex; flex-direction: column; @@ -38,7 +42,7 @@ input[type="number"], input[type="password"], input[type="search"], input[type="tel"], -input[type="text"], +input[type="text"]:not(:global(.bp4-editable-text-input)), input[type="time"], input[type="url"], input[type="week"], diff --git a/src/assets/i18n.json b/src/assets/i18n.json index 2a8c3cf31..6f8654473 100644 --- a/src/assets/i18n.json +++ b/src/assets/i18n.json @@ -86,7 +86,8 @@ "ban": "Veto", "pocketPick": "Pocket Pick", "reset": "Reset", - "cancel": "Cancel" + "cancel": "Cancel", + "winner": "Pick Winner" }, "noValidCharts": "No valid charts", "showSongPool": "Show eligible charts", @@ -179,7 +180,8 @@ "ban": "Veto", "pocketPick": "Pocket Pick", "reset": "Reset", - "cancel": "Cancel" + "cancel": "Cancel", + "winner": "Pick Winner" }, "noValidCharts": "No valid charts", "showSongPool": "Show eligible charts", diff --git a/src/card-draw.ts b/src/card-draw.ts index 4d55d9c23..47b953365 100644 --- a/src/card-draw.ts +++ b/src/card-draw.ts @@ -1,9 +1,16 @@ +import { nanoid } from "nanoid"; import { GameData, Song, Chart } from "./models/SongData"; import { times } from "./utils"; -import { DrawnChart, Drawing } from "./models/Drawing"; +import { DrawnChart, EligibleChart, Drawing } from "./models/Drawing"; import { ConfigState } from "./config-state"; - -export function getDrawnChart(currentSong: Song, chart: Chart): DrawnChart { +import { getDifficultyColor } from "./hooks/useDifficultyColor"; +import { getDiffAbbr } from "./game-data-utils"; + +export function getDrawnChart( + gameData: GameData, + currentSong: Song, + chart: Chart +): EligibleChart { return { name: currentSong.name, jacket: chart.jacket || currentSong.jacket, @@ -11,18 +18,15 @@ export function getDrawnChart(currentSong: Song, chart: Chart): DrawnChart { artist: currentSong.artist, artistTranslation: currentSong.artist_translation, bpm: currentSong.bpm, - difficultyClass: chart.diffClass, level: chart.lvl, flags: (chart.flags || []).concat(currentSong.flags || []), song: currentSong, + // Fill in variant data per game + diffAbbr: getDiffAbbr(gameData, chart.diffClass), + diffColor: getDifficultyColor(gameData, chart.diffClass), }; } -/** - * Used to give each drawing an auto-incrementing id - */ -let drawingID = 0; - /** returns true if song matches configured flags */ export function songIsValid( config: ConfigState, @@ -53,8 +57,8 @@ export function chartIsValid( ); } -export function* eligibleCharts(config: ConfigState, songs: Song[]) { - for (const currentSong of songs) { +export function* eligibleCharts(config: ConfigState, gameData: GameData) { + for (const currentSong of gameData.songs) { if (!songIsValid(config, currentSong)) { continue; } @@ -66,7 +70,7 @@ export function* eligibleCharts(config: ConfigState, songs: Song[]) { } // add chart to deck - yield getDrawnChart(currentSong, chart); + yield getDrawnChart(gameData, currentSong, chart); } } } @@ -88,12 +92,12 @@ export function draw(gameData: GameData, configData: ConfigState): Drawing { weights, } = configData; - const validCharts: Record> = {}; + const validCharts: Record> = {}; times(gameData.meta.lvlMax, (n) => { validCharts[n.toString()] = []; }); - for (const chart of eligibleCharts(configData, gameData.songs)) { + for (const chart of eligibleCharts(configData, gameData)) { validCharts[chart.level].push(chart); } @@ -159,10 +163,12 @@ export function draw(gameData: GameData, configData: ConfigState): Drawing { const randomChart = selectableCharts[randomIndex]; if (randomChart) { - // Give this random chart a unique id within this drawing - randomChart.id = drawnCharts.length; // Save it in our list of drawn charts - drawnCharts.push(randomChart); + drawnCharts.push({ + ...randomChart, + // Give this random chart a unique id within this drawing + id: drawnCharts.length, + }); // remove drawn chart from deck so it cannot be re-drawn selectableCharts.splice(randomIndex, 1); if (!difficultyCounts[chosenDifficulty]) { @@ -184,12 +190,12 @@ export function draw(gameData: GameData, configData: ConfigState): Drawing { } } - drawingID += 1; return { - id: drawingID, + id: nanoid(10), charts: drawnCharts, bans: [], protects: [], pocketPicks: [], + winners: [], }; } diff --git a/src/config-state.tsx b/src/config-state.tsx index 20314bb6b..e42ce36dc 100644 --- a/src/config-state.tsx +++ b/src/config-state.tsx @@ -1,4 +1,4 @@ -import createStore, { SetState } from "zustand"; +import createStore, { StoreApi } from "zustand"; export interface ConfigState { chartCount: number; @@ -6,6 +6,7 @@ export interface ConfigState { lowerBound: number; useWeights: boolean; orderByAction: boolean; + showVeto: boolean; weights: number[]; forceDistribution: boolean; constrainPocketPicks: boolean; @@ -13,7 +14,7 @@ export interface ConfigState { difficulties: ReadonlySet; flags: ReadonlySet; showPool: boolean; - update: SetState; + update: StoreApi["setState"]; } export const useConfigState = createStore((set, get) => ({ @@ -21,6 +22,7 @@ export const useConfigState = createStore((set, get) => ({ upperBound: 0, lowerBound: 0, useWeights: false, + showVeto: true, orderByAction: true, weights: [], forceDistribution: true, diff --git a/src/controls/controls.css b/src/controls/controls.css index c54d21aa4..419e47d28 100644 --- a/src/controls/controls.css +++ b/src/controls/controls.css @@ -1,15 +1,9 @@ -.form { +.drawer { padding: 1em 1.5em; overflow-y: auto; } -.form hr { - border-color: rgba(17, 20, 24, 0.8); - border-style: solid; - border-bottom-width: 0; -} - -.form label { +.drawer label { user-select: none; } diff --git a/src/controls/index.tsx b/src/controls/index.tsx index fd71d280b..f044b62fd 100644 --- a/src/controls/index.tsx +++ b/src/controls/index.tsx @@ -19,12 +19,19 @@ import { Switch, NavbarDivider, DrawerSize, + Divider, + Tabs, + Tab, + Icon, } from "@blueprintjs/core"; import { Tooltip2 } from "@blueprintjs/popover2"; import { IconNames } from "@blueprintjs/icons"; import { useIsNarrow } from "../hooks/useMediaQuery"; -import { EligibleChartsListFilter } from "../eligible-charts-list"; +import { EligibleChartsListFilter } from "../eligible-charts/filter"; import shallow from "zustand/shallow"; +import { TournamentModeToggle } from "../tournament-mode/tournament-mode-toggle"; +import { RemotePeerControls } from "../tournament-mode/remote-peer-menu"; +import { useRemotePeers } from "../tournament-mode/remote-peers"; function getAvailableDifficulties(gameData: GameData, selectedStyle: string) { let s = new Set(); @@ -100,10 +107,7 @@ export function HeaderControls() { function handleDraw() { useConfigState.setState({ showPool: false }); - const couldDraw = drawSongs(useConfigState.getState()); - if (couldDraw !== !lastDrawFailed) { - setLastDrawFailed(!couldDraw); - } + drawSongs(useConfigState.getState()); } function openSettings() { @@ -119,13 +123,10 @@ export function HeaderControls() { size={isNarrow ? DrawerSize.LARGE : "500px"} onClose={() => setSettingsOpen(false)} title={ - + } > - + {!isNarrow && ( <> @@ -158,7 +159,28 @@ export function HeaderControls() { ); } -function Controls() { +function ControlsDrawer() { + const isConnected = useRemotePeers((r) => !!r.thisPeer); + const hasPeers = useRemotePeers((r) => !!r.remotePeers.size); + return ( +
+ + }> + General + + }> + {" "} + Networking + + +
+ ); +} + +function GeneralSettings() { const { t } = useIntl(); const [dataSetName, gameData] = useDrawState( (s) => [s.dataSetName, s.gameData], @@ -169,6 +191,7 @@ function Controls() { useWeights, constrainPocketPicks, orderByAction, + showVeto, lowerBound, upperBound, update: updateState, @@ -209,7 +232,7 @@ function Controls() { }; return ( -
e.preventDefault()}> + <> {isNarrow && ( <> @@ -220,7 +243,7 @@ function Controls() { )} -
+ )}
@@ -358,6 +381,15 @@ function Controls() { }} label={t("orderByAction")} /> + { + const next = !!e.currentTarget.checked; + updateState({ showVeto: next }); + }} + label={t("showVeto", undefined, "Show vetoed charts")} + /> + {useWeights && } - + ); } diff --git a/src/controls/input-button-pair.tsx b/src/controls/input-button-pair.tsx new file mode 100644 index 000000000..c3177154f --- /dev/null +++ b/src/controls/input-button-pair.tsx @@ -0,0 +1,40 @@ +import { ControlGroup, InputGroup, Button } from "@blueprintjs/core"; +import { InputHTMLAttributes, ReactNode, useRef } from "react"; + +interface Props { + placeholder?: string; + value?: string; + rightElement?: JSX.Element; + disableInput?: boolean; + disableButton?: boolean; + /** called when the user clicks the button or presses the enter key */ + onClick(value: string, element: HTMLInputElement): void; + buttonLabel: ReactNode; + enterKeyHint?: InputHTMLAttributes["enterKeyHint"]; +} + +export function InputButtonPair(props: Props) { + const inputRef = useRef(null); + const handleClick = () => + props.onClick(inputRef.current!.value, inputRef.current!); + return ( + + { + if (e.code === "Enter") { + handleClick(); + } + }} + enterKeyHint={props.enterKeyHint} + /> + + + ); +} diff --git a/src/draw-state.tsx b/src/draw-state.tsx index a55ba868e..b3d9b4e73 100644 --- a/src/draw-state.tsx +++ b/src/draw-state.tsx @@ -12,8 +12,11 @@ import { IntlProvider } from "./intl-provider"; import * as qs from "query-string"; import createStore from "zustand"; import shallow from "zustand/shallow"; +import { DataConnection } from "peerjs"; +import { initShareWithPeer } from "./zustand/shared-zustand"; interface DrawState { + tournamentMode: boolean; gameData: GameData | null; fuzzySearch: FuzzySearch | null; drawings: Drawing[]; @@ -22,14 +25,20 @@ interface DrawState { loadGameData(dataSetName: string): Promise; /** returns false if no songs could be drawn */ drawSongs(config: ConfigState): boolean; + toggleTournamentMode(): void; + injectRemoteDrawing(d: Drawing, syncWithPeer?: DataConnection): void; } export const useDrawState = createStore((set, get) => ({ + tournamentMode: true, gameData: null, fuzzySearch: null, drawings: [], dataSetName: "", lastDrawFailed: false, + toggleTournamentMode() { + set((prev) => ({ tournamentMode: !prev.tournamentMode })); + }, async loadGameData(dataSetName: string) { const state = get(); if (state.dataSetName === dataSetName && state.gameData) { @@ -84,12 +93,32 @@ export const useDrawState = createStore((set, get) => ({ return false; } - set((prevState) => ({ - drawings: [drawing, ...prevState.drawings].filter(Boolean), - lastDrawFailed: 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, + }; + }); + }, })); interface Props { diff --git a/src/drawing-context.tsx b/src/drawing-context.tsx new file mode 100644 index 000000000..65d3646a8 --- /dev/null +++ b/src/drawing-context.tsx @@ -0,0 +1,155 @@ +import { ReactNode } from "react"; +import { draw } from "./card-draw"; +import { useConfigState } from "./config-state"; +import { createContextualStore } from "./zustand/contextual-zustand"; +import { useDrawState } from "./draw-state"; +import { + Drawing, + EligibleChart, + PlayerActionOnChart, + PocketPick, +} from "./models/Drawing"; +import { SerializibleStore } from "./zustand/shared-zustand"; + +const stubDrawing: Drawing = { + id: "stub", + charts: [], + bans: [], + pocketPicks: [], + protects: [], + winners: [], +}; + +interface DrawingProviderProps { + initialDrawing: Drawing; + children?: ReactNode; +} + +export interface DrawingContext extends Drawing, SerializibleStore { + updateDrawing: (d: Partial) => void; + redrawChart(chartId: number): void; + resetChart(chartId: number): 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: number, + player: 1 | 2, + chart?: EligibleChart + ): void; + setWinner(chartId: number, p: 1 | 2 | null): void; +} + +function keyFromAction(action: "ban" | "protect" | "pocket") { + switch (action) { + case "ban": + return "bans"; + case "protect": + return "protects"; + case "pocket": + return "pocketPicks"; + } +} + +const { + Provider: DrawingProvider, + useContextValue: useDrawing, + StoreIndex: allDrawingStores, + useStore: useDrawingStore, +} = createContextualStore( + (props, set, get) => ({ + ...props.initialDrawing, + updateDrawing: set, + 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; + }), + })); + }, + 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[]; + + if (useConfigState.getState().orderByAction) { + const indexToCut = charts.findIndex((chart) => chart.id === chartId); + const [shiftedChart] = charts.splice(indexToCut, 1); + if (action === "ban") { + // insert at tail of list + const insertPoint = charts.length; + charts.splice(insertPoint, 0, shiftedChart); + } else { + // insert at head of list, behind other picks + const insertPoint = + drawing.protects.length + drawing.pocketPicks.length; + charts.splice(insertPoint, 0, shiftedChart); + } + set({ + charts, + }); + } + + const existingIndex = arr.findIndex((b) => b.chartId === chartId); + if (existingIndex >= 0) { + arr.splice(existingIndex, 1); + } else { + arr.push({ player, pick: newChart!, chartId }); + } + 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 }; diff --git a/src/drawing-list.tsx b/src/drawing-list.tsx index 19bd673e0..ae4859e2f 100644 --- a/src/drawing-list.tsx +++ b/src/drawing-list.tsx @@ -1,29 +1,34 @@ -import { memo } from "react"; -import { DrawnSet } from "./drawn-set"; +import { lazy, memo, useDeferredValue } from "react"; import styles from "./drawing-list.css"; import { useDrawState } from "./draw-state"; -import { Drawing } from "./models/Drawing"; import { useConfigState } from "./config-state"; -import { EligibleChartsList } from "./eligible-charts-list"; import { Callout, NonIdealState } from "@blueprintjs/core"; import { IconNames } from "@blueprintjs/icons"; import logo from "./assets/ddr-tools-256.png"; -const renderDrawing = (drawing: Drawing) => ( - -); +const EligibleChartsList = lazy(() => import("./eligible-charts")); +const DrawnSet = lazy(() => import("./drawn-set")); -const ScrollableDrawings = memo((props: { drawings: Drawing[] }) => { - return
{props.drawings.map(renderDrawing)}
; +const ScrollableDrawings = memo(() => { + const drawings = useDeferredValue(useDrawState((s) => s.drawings)); + return ( +
+ {drawings.map((d) => ( + + ))} +
+ ); }); export function DrawingList() { - const drawings = useDrawState((s) => s.drawings); - const showPool = useConfigState((cfg) => cfg.showPool); + const hasDrawings = useDeferredValue( + useDrawState((s) => !!s.drawings.length) + ); + const showPool = useDeferredValue(useConfigState((cfg) => cfg.showPool)); if (showPool) { return ; } - if (!drawings.length) { + if (!hasDrawings) { return (
- DDR Card Draw now has a new name and URL. Look for more new - features coming soon! + + Networking features now available! Start with the new tab in + settings. } />
); } - return ; + return ; } diff --git a/src/drawn-set.css b/src/drawn-set.css index bb2f3e173..c9b7707c1 100644 --- a/src/drawn-set.css +++ b/src/drawn-set.css @@ -1,6 +1,20 @@ +:root { + --drawing-grad-saturation: 40%; + --drawing-grad-lightness: 85%; +} + +body:global(.bp4-dark) { + --drawing-grad-saturation: 25%; + --drawing-grad-lightness: 15%; +} + .chartList { display: flex; padding: 10px; overflow: hidden; flex-wrap: wrap; } + +.drawing { + position: relative; +} diff --git a/src/drawn-set.tsx b/src/drawn-set.tsx index 3494bdb42..096101f76 100644 --- a/src/drawn-set.tsx +++ b/src/drawn-set.tsx @@ -1,23 +1,18 @@ import { memo, useState } from "react"; import { SongCard } from "./song-card"; import styles from "./drawn-set.css"; -import { - Drawing, - DrawnChart, - PlayerActionOnChart, - PocketPick, -} from "./models/Drawing"; -import { useConfigState } from "./config-state"; -import { useForceUpdate } from "./hooks/useForceUpdate"; -import { draw } from "./card-draw"; -import { useDrawState } from "./draw-state"; +import { Drawing } from "./models/Drawing"; +import { SetLabels } from "./tournament-mode/drawing-labels"; +import { DrawingProvider, useDrawing } from "./drawing-context"; +import { NetworkingActions } from "./tournament-mode/networking-actions"; +import { SyncWithPeers } from "./tournament-mode/sync-with-peers"; const HUE_STEP = (255 / 8) * 3; let hue = Math.floor(Math.random() * 255); function getRandomGradiant() { hue += HUE_STEP; - return `linear-gradient(hsl(${hue}, 40%, 80%), transparent, transparent)`; + return `linear-gradient(hsl(${hue}, var(--drawing-grad-saturation), var(--drawing-grad-lightness)), transparent, transparent)`; } interface Props { @@ -25,117 +20,63 @@ interface Props { } function DrawnSetImpl({ drawing }: Props) { - const forceUpdate = useForceUpdate(); - const gameData = useDrawState((s) => s.gameData); const [backgroundImage] = useState(getRandomGradiant()); - function renderChart(chart: DrawnChart) { - const veto = drawing.bans.find((b) => b.chartId === chart.id); - const protect = drawing.protects.find((b) => b.chartId === chart.id); - const pocketPick = drawing.pocketPicks.find((b) => b.chartId === chart.id); - return ( - - ); - } - - /** - * handles any of the protect/pocket-pick/ban actions a user may take on a drawn chart - * @param arr array containing any previous actions of this type, to be mutated - * @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 - */ - function handleBanProtectReplace( - arr: Array | Array, - chartId: number, - player: 1 | 2, - chart?: DrawnChart - ) { - if (useConfigState.getState().orderByAction) { - const indexToCut = drawing.charts.findIndex( - (chart) => chart.id === chartId - ); - const [shiftedChart] = drawing.charts.splice(indexToCut, 1); - if (arr === drawing.bans) { - // insert at tail of list - const insertPoint = drawing.charts.length; - drawing.charts.splice(insertPoint, 0, shiftedChart); - } else { - // insert at head of list, behind other picks - const insertPoint = - drawing.protects.length + drawing.pocketPicks.length; - drawing.charts.splice(insertPoint, 0, shiftedChart); - } - } - - const existingBanIndex = arr.findIndex((b) => b.chartId === chart?.id); - if (existingBanIndex >= 0) { - arr.splice(existingBanIndex, 1); - } else { - arr.push({ player, pick: chart!, chartId }); - } - forceUpdate(); - } + return ( + + +
+ + + +
+
+ ); +} - function handleRedraw(chartId: number) { - const newDrawing = draw(gameData!, { - ...useConfigState.getState(), - chartCount: 1, - }); - newDrawing.charts[0].id = chartId; - drawing.charts = drawing.charts.map((chart) => { - if (chart.id === chartId) { - return newDrawing.charts[0]; - } - return chart; - }); - forceUpdate(); - } +function ChartList() { + const charts = useDrawing((d) => d.charts); + return ( +
+ {charts.map((c) => ( + + ))} +
+ ); +} - function handleReset(chartId: number) { - drawing.bans = drawing.bans.filter((p) => p.chartId !== chartId); - drawing.protects = drawing.protects.filter((p) => p.chartId !== chartId); - drawing.pocketPicks = drawing.pocketPicks.filter( - (p) => p.chartId !== chartId - ); - forceUpdate(); +function ChartFromContext({ chartId }: { chartId: number }) { + 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) + ); + if (!chart) { + return null; } - return ( -
- {drawing.charts.map(renderChart)} -
+ ); } -export const DrawnSet = memo(DrawnSetImpl); +const DrawnSet = memo(DrawnSetImpl); + +export default DrawnSet; diff --git a/src/eligible-charts-list.tsx b/src/eligible-charts-list.tsx deleted file mode 100644 index 0c1f4dd40..000000000 --- a/src/eligible-charts-list.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { eligibleCharts } from "./card-draw"; -import { useConfigState } from "./config-state"; -import { useDrawState } from "./draw-state"; -import { SongCard } from "./song-card"; -import styles from "./drawing-list.css"; -import { DrawnChart } from "./models/Drawing"; -import { - HTMLSelect, - Navbar, - NavbarGroup, - NavbarDivider, - Spinner, -} from "@blueprintjs/core"; -import { useIntl } from "./hooks/useIntl"; -import { useIsNarrow } from "./hooks/useMediaQuery"; -import { atom, useAtom } from "jotai"; -import { DiffHistogram } from "./histogram"; - -function songKeyFromChart(chart: DrawnChart) { - return `${chart.name}:${chart.artist}`; -} - -const currentTabAtom = atom("all"); - -export function EligibleChartsListFilter() { - const { t } = useIntl(); - const [currentTab, setCurrentTab] = useAtom(currentTabAtom); - const selectedFlags = Array.from(useConfigState((cfg) => cfg.flags)); - - if (!selectedFlags.length) { - return null; - } - - selectedFlags.unshift("all"); - - return ( - setCurrentTab(e.currentTarget.value)} - options={Array.from(selectedFlags).map((flag) => ({ - value: flag, - label: flag === "all" ? "All charts" : t(`meta.${flag}`), - }))} - /> - ); -} - -export function EligibleChartsList() { - const [currentTab] = useAtom(currentTabAtom); - const gameData = useDrawState((s) => s.gameData); - const configState = useConfigState(); - const isNarrow = useIsNarrow(); - const isDisplayFiltered = currentTab !== "all"; - - if (!gameData) { - return ; - } - let charts = Array.from(eligibleCharts(configState, gameData.songs)); - const songs = new Set(); - const filteredCharts = charts.filter((chart) => { - songs.add(songKeyFromChart(chart)); - if (isDisplayFiltered && chart.flags.every((f) => f !== currentTab)) { - return false; - } - return true; - }); - - return ( - <> - - - {charts.length} eligible charts from {songs.size} songs (of{" "} - {gameData.songs.length} total) - - {configState.flags.size > 0 && !isNarrow && ( - - - - - )} - - -
- {filteredCharts.map((chart, idx) => ( - - ))} -
- - ); -} diff --git a/src/eligible-charts/filter.tsx b/src/eligible-charts/filter.tsx new file mode 100644 index 000000000..ca1a2ad5e --- /dev/null +++ b/src/eligible-charts/filter.tsx @@ -0,0 +1,29 @@ +import { HTMLSelect } from "@blueprintjs/core"; +import { atom, useAtom } from "jotai"; +import { useConfigState } from "../config-state"; +import { useIntl } from "../hooks/useIntl"; + +export const currentTabAtom = atom("all"); + +export function EligibleChartsListFilter() { + const { t } = useIntl(); + const [currentTab, setCurrentTab] = useAtom(currentTabAtom); + const selectedFlags = Array.from(useConfigState((cfg) => cfg.flags)); + + if (!selectedFlags.length) { + return null; + } + + selectedFlags.unshift("all"); + + return ( + setCurrentTab(e.currentTarget.value)} + options={Array.from(selectedFlags).map((flag) => ({ + value: flag, + label: flag === "all" ? "All charts" : t(`meta.${flag}`), + }))} + /> + ); +} diff --git a/src/histogram.tsx b/src/eligible-charts/histogram.tsx similarity index 77% rename from src/histogram.tsx rename to src/eligible-charts/histogram.tsx index 21ecd0392..f3dfcc35c 100644 --- a/src/histogram.tsx +++ b/src/eligible-charts/histogram.tsx @@ -1,4 +1,4 @@ -import { DrawnChart } from "./models/Drawing"; +import { EligibleChart } from "../models/Drawing"; import { VictoryChart, VictoryBar, @@ -8,15 +8,15 @@ import { VictoryLabel, } from "victory"; import { useMemo } from "react"; -import { CountingSet } from "./utils"; -import { useDrawState } from "./draw-state"; -import { useIntl } from "./hooks/useIntl"; -import { getMetaString } from "./game-data-utils"; -import { Theme, useTheme } from "./theme-toggle"; -import { useIsNarrow } from "./hooks/useMediaQuery"; +import { CountingSet } from "../utils"; +import { useDrawState } from "../draw-state"; +import { useIntl } from "../hooks/useIntl"; +import { getDiffClass, getMetaString } from "../game-data-utils"; +import { Theme, useTheme } from "../theme-toggle"; +import { useIsNarrow } from "../hooks/useMediaQuery"; interface Props { - charts: DrawnChart[]; + charts: EligibleChart[]; } export function DiffHistogram({ charts }: Props) { @@ -29,15 +29,15 @@ export function DiffHistogram({ charts }: Props) { let maxBar = 0; const allLevels = new CountingSet(); for (const chart of charts) { - if (!countByClassAndLvl[chart.difficultyClass]) { - countByClassAndLvl[chart.difficultyClass] = new CountingSet(); + if (!countByClassAndLvl[chart.diffAbbr]) { + countByClassAndLvl[chart.diffAbbr] = new CountingSet(); } - countByClassAndLvl[chart.difficultyClass].add(chart.level); + countByClassAndLvl[chart.diffAbbr].add(chart.level); maxBar = Math.max(maxBar, allLevels.add(chart.level)); } const orderedLevels = Array.from(allLevels.values()).sort((a, b) => a - b); const difficulties = allDiffs - .filter((d) => !!countByClassAndLvl[d.key]) + .filter((d) => !!countByClassAndLvl[getDiffClass(t, d.key)]) .reverse(); const dataPerDiff = difficulties.map((diff) => ({ color: diff.color, @@ -45,7 +45,7 @@ export function DiffHistogram({ charts }: Props) { label: getMetaString(t, diff.key), data: orderedLevels.map((lvl) => ({ level: lvl, - count: countByClassAndLvl[diff.key].get(lvl) || 0, + count: countByClassAndLvl[getDiffClass(t, diff.key)].get(lvl) || 0, })), })); return [ diff --git a/src/eligible-charts/index.tsx b/src/eligible-charts/index.tsx new file mode 100644 index 000000000..4d6df2a09 --- /dev/null +++ b/src/eligible-charts/index.tsx @@ -0,0 +1,69 @@ +import { eligibleCharts } from "../card-draw"; +import { useConfigState } from "../config-state"; +import { useDrawState } from "../draw-state"; +import { SongCard } from "../song-card"; +import styles from "../drawing-list.css"; +import { EligibleChart } from "../models/Drawing"; +import { Navbar, NavbarGroup, NavbarDivider, Spinner } from "@blueprintjs/core"; +import { useIsNarrow } from "../hooks/useMediaQuery"; +import { useAtom } from "jotai"; +import { useDeferredValue, useMemo } from "react"; +import { currentTabAtom, EligibleChartsListFilter } from "./filter"; +import { DiffHistogram } from "./histogram"; + +function songKeyFromChart(chart: EligibleChart) { + return `${chart.name}:${chart.artist}`; +} + +export default function EligibleChartsList() { + const gameData = useDrawState((s) => s.gameData); + const [currentTab] = useDeferredValue(useAtom(currentTabAtom)); + const configState = useDeferredValue(useConfigState()); + const isNarrow = useIsNarrow(); + const isDisplayFiltered = currentTab !== "all"; + + if (!gameData) { + return ; + } + const charts = Array.from(eligibleCharts(configState, gameData)); + const songs = new Set(); + const filteredCharts = useMemo( + () => + charts.filter((chart) => { + songs.add(songKeyFromChart(chart)); + if (isDisplayFiltered && chart.flags.every((f) => f !== currentTab)) { + return false; + } + return true; + }), + [currentTab, charts] + ); + + return ( + <> + + + {charts.length} eligible charts from {songs.size} songs (of{" "} + {gameData.songs.length} total) + + {configState.flags.size > 0 && !isNarrow && ( + + + + + )} + + +
+ {filteredCharts.map((chart, idx) => ( + + ))} +
+ + ); +} diff --git a/src/game-data-utils.tsx b/src/game-data-utils.tsx index afa11710a..f82a496a4 100644 --- a/src/game-data-utils.tsx +++ b/src/game-data-utils.tsx @@ -1,4 +1,5 @@ import { useIntl } from "./hooks/useIntl"; +import { GameData, I18NDict } from "./models/SongData"; export function getMetaString(t: (key: string) => string, key: string) { return t("meta." + key); @@ -21,3 +22,9 @@ export function AbbrDifficulty({ diffClass }: AbbrProps) { const { t } = useIntl(); return <>{getDiffClass(t, diffClass)}; } + +export function getDiffAbbr(gameData: GameData, diffClass: string) { + return ((gameData.i18n.en as I18NDict)["$abbr"] as I18NDict)[ + diffClass + ] as string; +} diff --git a/src/hooks/useDifficultyColor.ts b/src/hooks/useDifficultyColor.ts index fc0b18724..4d46903cc 100644 --- a/src/hooks/useDifficultyColor.ts +++ b/src/hooks/useDifficultyColor.ts @@ -1,12 +1,14 @@ -import { useDrawState } from "../draw-state"; +import { GameData } from "../models/SongData"; /** * Returns the CSS color specified in the game file metadata for a given difficulty */ -export function useDifficultyColor(difficultyClass: string): string { - const gameData = useDrawState((s) => s.gameData); - const diffAccentColor = - gameData?.meta.difficulties.find((d) => d.key === difficultyClass)?.color || - ""; - return diffAccentColor; +export function getDifficultyColor( + gameData: GameData, + difficultyClass: string +): string { + return ( + gameData.meta.difficulties.find((d) => d.key === difficultyClass)?.color || + "" + ); } diff --git a/src/hooks/useIntl.ts b/src/hooks/useIntl.ts index f4fb0babe..17042a97c 100644 --- a/src/hooks/useIntl.ts +++ b/src/hooks/useIntl.ts @@ -7,8 +7,11 @@ export function useIntl() { const { formatMessage } = useReactIntl(); return useMemo( () => ({ - t: (id: string, values?: Record) => - formatMessage({ id }, values), + t: ( + id: string, + values?: Record, + defaultMessage?: string + ) => formatMessage({ id, defaultMessage }, values), }), [formatMessage] ); diff --git a/src/index.tsx b/src/index.tsx index 5e86ab088..d4f3f0cd7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,10 +1,11 @@ import { App } from "./app"; -import { render } from "react-dom"; +import { createRoot } from "react-dom/client"; import styles from "./app.css"; const appRoot = document.createElement("main"); document.body.prepend(appRoot); appRoot.className = styles.container; -render(, appRoot); +const root = createRoot(appRoot); +root.render(); import("./vitals") .then((v) => v.webVitals()) diff --git a/src/models/Drawing.ts b/src/models/Drawing.ts index 5935228b1..0cf9aa40d 100644 --- a/src/models/Drawing.ts +++ b/src/models/Drawing.ts @@ -1,17 +1,22 @@ +import { DataConnection } from "peerjs"; import { Song } from "./SongData"; -export interface DrawnChart { +export interface EligibleChart { name: string; jacket: string; nameTranslation?: string; artist: string; artistTranslation?: string; bpm: string; - difficultyClass: string; + diffAbbr: string; + diffColor: string; level: number; flags: string[]; song: Song; - id?: number; +} + +export interface DrawnChart extends EligibleChart { + id: number; } export interface PlayerActionOnChart { @@ -20,13 +25,19 @@ export interface PlayerActionOnChart { } export interface PocketPick extends PlayerActionOnChart { - pick: DrawnChart; + pick: EligibleChart; } export interface Drawing { - id: number; + id: string; + title?: string; + player1?: string; + player2?: string; charts: DrawnChart[]; bans: Array; protects: Array; + winners: Array; pocketPicks: Array; + /** __ prefix avoids serializing this field during sync */ + __syncPeer?: DataConnection; } diff --git a/src/song-card/card-label.css b/src/song-card/card-label.css index 5198dd136..633d48aae 100644 --- a/src/song-card/card-label.css +++ b/src/song-card/card-label.css @@ -6,6 +6,11 @@ text-shadow: none; } +.cardLabel.winner { + top: unset; + bottom: 2.5em; +} + .cardLabel > span { border: 2px solid white; border-radius: 0.5em; diff --git a/src/song-card/card-label.tsx b/src/song-card/card-label.tsx index 185270ca3..5d65b5660 100644 --- a/src/song-card/card-label.tsx +++ b/src/song-card/card-label.tsx @@ -1,12 +1,15 @@ +import classNames from "classnames"; import React from "react"; import { Intent, Tag } from "@blueprintjs/core"; import styles from "./card-label.css"; import { IconNames } from "@blueprintjs/icons"; +import { usePlayerLabel } from "./use-player-label"; export enum LabelType { Protect = 1, Ban, Pocket, + Winner, } interface Props { @@ -23,6 +26,8 @@ function getIntent(type: LabelType) { return Intent.DANGER; case LabelType.Protect: return Intent.SUCCESS; + case LabelType.Winner: + return Intent.WARNING; } } @@ -34,19 +39,26 @@ function getIcon(type: LabelType) { return IconNames.BAN_CIRCLE; case LabelType.Protect: return IconNames.LOCK; + case LabelType.Winner: + return IconNames.Crown; } } export function CardLabel({ player, type, onRemove }: Props) { + const label = usePlayerLabel(player); + + const rootClassname = classNames(styles.cardLabel, { + [styles.winner]: type === LabelType.Winner, + }); return ( -
+
- P{player} + {label}
); diff --git a/src/song-card/icon-menu.tsx b/src/song-card/icon-menu.tsx index b98ad0c7f..cc815314b 100644 --- a/src/song-card/icon-menu.tsx +++ b/src/song-card/icon-menu.tsx @@ -1,38 +1,57 @@ import { useIntl } from "../hooks/useIntl"; import { IconNames, IconName } from "@blueprintjs/icons"; import { Menu, MenuItem, MenuDivider } from "@blueprintjs/core"; +import { usePlayerLabel } from "./use-player-label"; interface Props { - onStartPocketPick: (p: 1 | 2) => void; - onVeto: (p: 1 | 2) => void; - onProtect: (p: 1 | 2) => void; - onRedraw: () => void; + onStartPocketPick?: (p: 1 | 2) => void; + onVeto?: (p: 1 | 2) => void; + onProtect?: (p: 1 | 2) => void; + onRedraw?: () => void; + onSetWinner?: (p: 1 | 2 | null) => void; } export function IconMenu(props: Props) { - const { onStartPocketPick, onVeto, onProtect, onRedraw } = props; + const { onStartPocketPick, onVeto, onProtect, onRedraw, onSetWinner } = props; const { t } = useIntl(); return ( - - - - - + {onProtect && ( + + )} + {onStartPocketPick && ( + + )} + {onVeto && ( + + )} + {onSetWinner && ( + + )} + {onRedraw && ( + <> + + + + )} ); } @@ -46,8 +65,16 @@ interface IconRowProps { function MenuPair({ icon, text, onClick }: IconRowProps) { return ( - onClick(1)} icon={IconNames.PERSON} /> - onClick(2)} icon={IconNames.PERSON} /> + onClick(1)} + icon={IconNames.PERSON} + /> + onClick(2)} + icon={IconNames.PERSON} + /> ); } diff --git a/src/song-card/song-card.css b/src/song-card/song-card.css index 3c3fc4995..f040356a0 100644 --- a/src/song-card/song-card.css +++ b/src/song-card/song-card.css @@ -119,3 +119,7 @@ border-color: cornflowerblue; box-shadow: 0px 0px 5px cornflowerblue; } + +.hideVeto.vetoed { + display: none !important; +} diff --git a/src/song-card/song-card.tsx b/src/song-card/song-card.tsx index cb9f2a145..98457ef12 100644 --- a/src/song-card/song-card.tsx +++ b/src/song-card/song-card.tsx @@ -1,15 +1,16 @@ import classNames from "classnames"; import { detectedLanguage } from "../utils"; import styles from "./song-card.css"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { IconMenu } from "./icon-menu"; import { CardLabel, LabelType } from "./card-label"; -import { DrawnChart } from "../models/Drawing"; -import { AbbrDifficulty } from "../game-data-utils"; -import { useDifficultyColor } from "../hooks/useDifficultyColor"; +import { DrawnChart, EligibleChart } from "../models/Drawing"; import { ShockBadge } from "./shock-badge"; import { Popover2 } from "@blueprintjs/popover2"; import { SongSearch } from "../song-search"; +import shallow from "zustand/shallow"; +import { useDrawing } from "../drawing-context"; +import { useConfigState } from "../config-state"; const isJapanese = detectedLanguage === "ja"; @@ -18,18 +19,44 @@ type Player = 1 | 2; interface IconCallbacks { onVeto: (p: Player) => void; onProtect: (p: Player) => void; - onReplace: (p: Player, chart: DrawnChart) => void; + onReplace: (p: Player, chart: EligibleChart) => void; onRedraw: () => void; onReset: () => void; + onSetWinner: (p: Player | null) => void; } interface Props { - chart: DrawnChart; + chart: DrawnChart | EligibleChart; vetoedBy?: Player; protectedBy?: Player; replacedBy?: Player; - replacedWith?: DrawnChart; - iconCallbacks?: IconCallbacks; + winner?: Player; + replacedWith?: EligibleChart; + actionsEnabled?: boolean; +} + +function useIconCallbacksForChart(chartId: number): IconCallbacks { + const [handleBanPickPocket, redrawChart, resetChart, setWinner] = useDrawing( + (d) => [ + d.handleBanProtectReplace, + d.redrawChart, + d.resetChart, + d.setWinner, + ], + shallow + ); + + return useMemo( + () => ({ + onVeto: handleBanPickPocket.bind(undefined, "ban", chartId), + onProtect: handleBanPickPocket.bind(undefined, "protect", chartId), + onReplace: handleBanPickPocket.bind(undefined, "pocket", chartId), + onRedraw: redrawChart.bind(undefined, chartId), + onReset: resetChart.bind(undefined, chartId), + onSetWinner: setWinner.bind(undefined, chartId), + }), + [handleBanPickPocket, redrawChart, resetChart, chartId] + ); } export function SongCard(props: Props) { @@ -39,14 +66,18 @@ export function SongCard(props: Props) { protectedBy, replacedBy, replacedWith, - iconCallbacks, + winner, + actionsEnabled, } = props; + const showVeto = useConfigState((s) => s.showVeto); - const [showingIconMenu, setShowIconMenu] = useState(false); - const showIcons = () => setShowIconMenu(true); - const hideIcons = () => setShowIconMenu(false); + const [showingContextMenu, setContextMenuOpen] = useState(false); + const showMenu = () => setContextMenuOpen(true); + const hideMenu = () => setContextMenuOpen(false); - const [pocketPickForPlayer, setPocketPickForPlayer] = useState<0 | 1 | 2>(0); + const [pocketPickPendingForPlayer, setPocketPickPendingForPlayer] = useState< + 0 | 1 | 2 + >(0); const { name, @@ -54,22 +85,15 @@ export function SongCard(props: Props) { artist, artistTranslation, bpm, - difficultyClass, + diffAbbr, + diffColor, level, jacket, flags, } = replacedWith || chart; - const diffAccentColor = useDifficultyColor(difficultyClass); const hasLabel = !!(vetoedBy || protectedBy || replacedBy); - const rootClassname = classNames(styles.chart, { - [styles.vetoed]: vetoedBy, - [styles.protected]: protectedBy, - [styles.replaced]: replacedBy, - [styles.clickable]: !!iconCallbacks && !hasLabel, - }); - let jacketBg = {}; if (jacket) { jacketBg = { @@ -77,36 +101,51 @@ export function SongCard(props: Props) { }; } + let iconCallbacks = useIconCallbacksForChart((chart as DrawnChart).id); + let menuContent: undefined | JSX.Element; - if (iconCallbacks) { - menuContent = ( - - ); + if (actionsEnabled && !winner) { + if (!hasLabel) { + menuContent = ( + + ); + } else if (!vetoedBy) { + menuContent = ; + } } + const rootClassname = classNames(styles.chart, { + [styles.vetoed]: vetoedBy, + [styles.protected]: protectedBy, + [styles.replaced]: replacedBy, + [styles.clickable]: !!menuContent, + [styles.hideVeto]: !showVeto, + }); + return (
{ - iconCallbacks && + actionsEnabled && chart && - iconCallbacks.onReplace(pocketPickForPlayer as 1 | 2, chart); - setPocketPickForPlayer(0); + iconCallbacks.onReplace(pocketPickPendingForPlayer as 1 | 2, chart); + setPocketPickPendingForPlayer(0); }} - onCancel={() => setPocketPickForPlayer(0)} + onCancel={() => setPocketPickPendingForPlayer(0)} /> {vetoedBy && ( )} + {winner && ( + iconCallbacks?.onSetWinner(null)} + /> + )}
{name} @@ -143,8 +189,8 @@ export function SongCard(props: Props) {
{bpm} BPM
{flags.includes("shock") && }
- {level} + {diffAbbr} {level}
diff --git a/src/song-card/use-player-label.ts b/src/song-card/use-player-label.ts new file mode 100644 index 000000000..55f413614 --- /dev/null +++ b/src/song-card/use-player-label.ts @@ -0,0 +1,5 @@ +import { useDrawing } from "../drawing-context"; + +export function usePlayerLabel(n: 1 | 2) { + return useDrawing((d) => d[`player${n}`] || `P${n}`); +} diff --git a/src/song-jacket.tsx b/src/song-jacket.tsx index 3a74c3223..62da9128f 100644 --- a/src/song-jacket.tsx +++ b/src/song-jacket.tsx @@ -1,5 +1,6 @@ import { Song } from "./models/SongData"; -import { Music } from "react-feather"; +import { Icon } from "@blueprintjs/core"; +import { IconNames } from "@blueprintjs/icons"; interface Props { song: Song; @@ -19,7 +20,7 @@ export function SongJacket(props: Props) { } return (
- +
); } diff --git a/src/song-search/index.tsx b/src/song-search/index.tsx index 040c16e22..40db0ef7c 100644 --- a/src/song-search/index.tsx +++ b/src/song-search/index.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { chartIsValid, getDrawnChart, songIsValid } from "../card-draw"; import { useConfigState } from "../config-state"; import { useDrawState } from "../draw-state"; -import { DrawnChart } from "../models/Drawing"; +import { EligibleChart } from "../models/Drawing"; import { Song } from "../models/SongData"; import { SearchResult, SearchResultData } from "./search-result"; import { Omnibar } from "@blueprintjs/select"; @@ -10,7 +10,7 @@ import styles from "./song-search.css"; interface Props { isOpen: boolean; - onSongSelect(song: Song, chart?: DrawnChart): void; + onSongSelect(song: Song, chart?: EligibleChart): void; onCancel(): void; } @@ -51,7 +51,11 @@ export function SongSearch(props: Props) { item.song, item.chart === "none" || !item.chart ? undefined - : getDrawnChart(item.song, item.chart) + : getDrawnChart( + useDrawState.getState().gameData!, + item.song, + item.chart + ) ) } items={items} @@ -61,7 +65,6 @@ export function SongSearch(props: Props) { className={styles.songSearch} itemRenderer={(data, itemProps) => ( void; -} - -function ChartOption({ chart, onClick }: ChartOptionProps) { - const bg = useDifficultyColor(chart.diffClass); - return ( -
- -
- {chart.lvl} -
- ); -} - interface ResultsProps { data: SearchResultData; selected: boolean; handleClick: React.MouseEventHandler; - config: ConfigState; } -export function SearchResult({ - data, - selected, - handleClick, - config, -}: ResultsProps) { +export function SearchResult({ data, selected, handleClick }: ResultsProps) { const song = data.song; const { t } = useIntl(); let label: string | JSX.Element; diff --git a/src/theme-toggle.tsx b/src/theme-toggle.tsx index a5d83af6d..91497ee16 100644 --- a/src/theme-toggle.tsx +++ b/src/theme-toggle.tsx @@ -21,7 +21,7 @@ export function useThemePref() { : Theme.Light; } -function applyTheme(theme: Theme) { +function applyThemeBodyClass(theme: Theme) { document.body.classList.toggle(Classes.DARK, theme === Theme.Dark); } @@ -54,7 +54,7 @@ export function ThemeSyncWidget() { const themeState = useThemeStore(); const browserPref = useThemePref(); useEffect(() => { - applyTheme(themeState.resolved); + applyThemeBodyClass(themeState.resolved); }, [themeState.resolved]); useEffect(() => { themeState.updateBrowserPref(browserPref); diff --git a/src/toaster.tsx b/src/toaster.tsx index 134b06831..430bf3a95 100644 --- a/src/toaster.tsx +++ b/src/toaster.tsx @@ -1,3 +1,15 @@ import { Toaster } from "@blueprintjs/core"; +import { createRoot } from "react-dom/client"; -export const toaster = Toaster.create({ position: "bottom" }); +export let toaster: Toaster; + +const toasterRoot = document.createElement("div"); +document.body.appendChild(toasterRoot); +createRoot(toasterRoot).render( + { + toaster = instance!; + }} + /> +); diff --git a/src/tournament-mode/RoundSelect.tsx b/src/tournament-mode/RoundSelect.tsx new file mode 100644 index 000000000..ed27b5f4f --- /dev/null +++ b/src/tournament-mode/RoundSelect.tsx @@ -0,0 +1,55 @@ +import { MenuItem } from "@blueprintjs/core"; +import { Suggest2 } from "@blueprintjs/select"; + +import { filterRoundLabel, renderRoundLabel, roundLabels } from "./round-label"; +import { useDrawing } from "../drawing-context"; + +const RoundLabel = Suggest2.ofType(); + +function identity(input: T): T { + return input; +} + +export function RoundSelect() { + const updateDrawing = useDrawing((d) => d.updateDrawing); + const tournamentTitle = useDrawing((d) => d.title); + + return ( + ( + + )} + itemPredicate={filterRoundLabel} + itemRenderer={renderRoundLabel} + noResults={ + + } + onItemSelect={(title) => updateDrawing({ title })} + selectedItem={tournamentTitle || null} + /> + ); +} diff --git a/src/tournament-mode/drawing-labels.css b/src/tournament-mode/drawing-labels.css new file mode 100644 index 000000000..20152dfbc --- /dev/null +++ b/src/tournament-mode/drawing-labels.css @@ -0,0 +1,28 @@ +.headers { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + font-size: 200%; + padding-top: 8px; + position: relative; +} + +.title { + display: flex; + flex-direction: row; + justify-content: space-between; + max-width: 500px; +} + +.versus { + height: 0px; +} + +.players { + display: flex; + flex-direction: row; + justify-content: space-between; + max-width: 600px; + width: 100%; +} diff --git a/src/tournament-mode/drawing-labels.tsx b/src/tournament-mode/drawing-labels.tsx new file mode 100644 index 000000000..15ce24445 --- /dev/null +++ b/src/tournament-mode/drawing-labels.tsx @@ -0,0 +1,51 @@ +import { EditableText } from "@blueprintjs/core"; +import { useCallback } from "react"; +import { useDrawState } from "../draw-state"; +import { useDrawing } from "../drawing-context"; +import styles from "./drawing-labels.css"; + +import { RoundSelect } from "./RoundSelect"; + +export function SetLabels() { + const tournamentMode = useDrawState((s) => s.tournamentMode); + if (!tournamentMode) { + return null; + } + + return ( +
+
+ +
+
vs
+
+ + +
+
+ ); +} + +function EditableDrawingField({ + field, + placeholder, +}: { + field: "title" | "player1" | "player2"; + placeholder: string; +}) { + const updateDrawing = useDrawing((s) => s.updateDrawing); + const value = useDrawing((s) => s[field]); + const handleChange = useCallback( + (value: string) => { + updateDrawing({ [field]: value }); + }, + [updateDrawing, field] + ); + return ( + + ); +} diff --git a/src/tournament-mode/networking-actions.css b/src/tournament-mode/networking-actions.css new file mode 100644 index 000000000..70e5ba864 --- /dev/null +++ b/src/tournament-mode/networking-actions.css @@ -0,0 +1,5 @@ +.sendButton { + position: absolute; + top: 0; + right: 5px; +} diff --git a/src/tournament-mode/networking-actions.tsx b/src/tournament-mode/networking-actions.tsx new file mode 100644 index 000000000..cae2ad6ba --- /dev/null +++ b/src/tournament-mode/networking-actions.tsx @@ -0,0 +1,86 @@ +import { Button, Icon, Menu, MenuItem } from "@blueprintjs/core"; +import { IconNames } from "@blueprintjs/icons"; +import { Popover2, Tooltip2 } from "@blueprintjs/popover2"; +import { ReactNode } from "react"; +import { useDrawState } from "../draw-state"; +import { useDrawing, useDrawingStore } from "../drawing-context"; +import { firstOf } from "../utils"; +import styles from "./networking-actions.css"; +import { CurrentPeersMenu } from "./remote-peer-menu"; +import { displayFromPeerId, useRemotePeers } from "./remote-peers"; + +export function NetworkingActions() { + const getDrawing = useDrawing((s) => s.serializeSyncFields); + const syncPeer = useDrawing((s) => s.__syncPeer); + const isConnected = useRemotePeers((s) => !!s.thisPeer); + const remotePeers = useRemotePeers((s) => s.remotePeers); + const sendDrawing = useRemotePeers((s) => s.sendDrawing); + const syncDrawing = useRemotePeers((s) => s.beginSyncWithPeer); + const drawingStore = useDrawingStore(); + const tournamentMode = useDrawState((s) => s.tournamentMode); + + if (!isConnected) { + return null; + } + + let remoteActions: JSX.Element | undefined = undefined; + + if (remotePeers.size === 1) { + const peerId = displayFromPeerId(firstOf(remotePeers.values())!.peer); + remoteActions = ( + + sendDrawing(getDrawing())} + /> + syncDrawing(drawingStore)} + /> + + ); + } else if (remotePeers.size > 1) { + remoteActions = ( + + + sendDrawing(getDrawing(), peerId)} + /> + + + syncDrawing(drawingStore, peerId)} + /> + + + ); + } else if (syncPeer) { + remoteActions = undefined; + } + + const button = ( +