diff --git a/package.json b/package.json index 797f678..c9431a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modaq", - "version": "1.32.2", + "version": "1.33.0", "description": "Quiz Bowl Reader using TypeScript, React, and MobX", "repository": { "type": "git", diff --git a/src/components/FilePicker.tsx b/src/components/FilePicker.tsx index 0c57e55..e4efec1 100644 --- a/src/components/FilePicker.tsx +++ b/src/components/FilePicker.tsx @@ -1,12 +1,45 @@ import React from "react"; import { observer } from "mobx-react-lite"; -import { Label, DefaultButton, mergeStyleSets } from "@fluentui/react"; +import { Label, DefaultButton, mergeStyleSets, Stack, StackItem, IStackStyles } from "@fluentui/react"; + +const fileDraggedOverStyle: IStackStyles = { + root: { + border: "1px dotted gray", + }, +}; export const FilePicker = observer(function FilePicker(props: React.PropsWithChildren): JSX.Element { + // Not worth keeping this state in UIState, so just track it here. This should only be used for styling + const [fileDraggedOver, setFileDraggedOver] = React.useState(false); + const fileInput: React.MutableRefObject = React.useRef(null); const changeHandler = React.useCallback( (event: React.ChangeEvent) => { - props.onChange(event, fileInput.current?.files); + const files: FileList | null | undefined = fileInput.current?.files; + if (files == undefined || files.length === 0) { + return; + } + + props.onChange(event, files[0]); + }, + [props] + ); + const dropHandler = React.useCallback( + (event: React.DragEvent) => { + setFileDraggedOver(false); + + if (event.dataTransfer.items.length === 0) { + return; + } + + const file: File | null = event.dataTransfer.items[0].getAsFile(); + if (file == undefined) { + return; + } + + event.preventDefault(); + + props.onChange(event, file); }, [props] ); @@ -16,24 +49,37 @@ export const FilePicker = observer(function FilePicker(props: React.PropsWithChi ); + // The border can make the border a little jumpy. A low priority issue for now. + const stackStyles: IStackStyles | undefined = fileDraggedOver ? fileDraggedOverStyle : undefined; + return ( - <> - {label} - - { - fileInput.current?.click(); - }} - /> - {props.children} - + { + setFileDraggedOver(true); + ev.preventDefault(); + }} + onDragLeave={() => setFileDraggedOver(false)} + styles={stackStyles} + > + {label} + + + { + fileInput.current?.click(); + }} + /> + {props.children} + + ); }); @@ -43,7 +89,7 @@ export interface IFilePickerProps { label?: string; required?: boolean; - onChange(event: React.ChangeEvent, fileList: FileList | null | undefined): void; + onChange(event: React.SyntheticEvent, file: File): void; } interface IFileInputClassNames { diff --git a/src/components/ModaqControl.tsx b/src/components/ModaqControl.tsx index 5b8b172..47929b0 100644 --- a/src/components/ModaqControl.tsx +++ b/src/components/ModaqControl.tsx @@ -28,8 +28,10 @@ import { ModalDialogContainer } from "./ModalDialogContainer"; import { IGameFormat } from "../state/IGameFormat"; import { IPacket } from "../state/IPacket"; import { IPlayer, Player } from "../state/TeamState"; -import { PacketState } from "../state/PacketState"; +import { Bonus, PacketState } from "../state/PacketState"; import { ICustomExport } from "../state/CustomExport"; +import { Cycle } from "../state/Cycle"; +import { ModalVisibilityStatus } from "../state/ModalVisibilityStatus"; // Initialize Fluent UI icons when this is loaded, before the first render initializeIcons(); @@ -228,6 +230,11 @@ function initializeControl(appState: AppState, props: IModaqControlProps): () => } function shortcutHandler(event: KeyboardEvent, appState: AppState): void { + // Disable shortcuts if there's a modal dialog open + if (appState.uiState.dialogState.visibleDialog !== ModalVisibilityStatus.None) { + return; + } + switch (event.key.toUpperCase()) { case "N": if (appState.uiState.cycleIndex + 1 < appState.game.playableCycles.length) { @@ -237,12 +244,58 @@ function shortcutHandler(event: KeyboardEvent, appState: AppState): void { event.stopPropagation(); break; + case "P": case "B": appState.uiState.previousCycle(); event.preventDefault(); event.stopPropagation(); break; + + case "1": + case "2": + case "3": + case "4": + case "5": + // This shortcut is only supported when bouncebacks are disabled. We could add support for it later and just + // toggle through all the fields, but the logic is slightly more complicated and very few formats use + // bouncebacks. + if (appState.game.gameFormat.bonusesBounceBack) { + event.preventDefault(); + event.stopPropagation(); + + return; + } + + // If there are bonuses and they are active, toggle the bonus part. We have to do some defensive checks to + // make sure that we can update the bonus + const cycleIndex: number = appState.uiState.cycleIndex; + const cycle: Cycle = appState.game.cycles[cycleIndex]; + const bonus: Bonus | undefined = appState.game.getBonus(cycleIndex); + if (cycle.correctBuzz == undefined || bonus == undefined) { + event.preventDefault(); + event.stopPropagation(); + + return; + } + + // We know event.key is a number here, so we don't need to check for NaN + const partIndex: number = Number(event.key) - 1; + if (bonus.parts.length <= partIndex) { + event.preventDefault(); + event.stopPropagation(); + + return; + } + + const teamName: string = cycle.correctBuzz.marker.player.teamName; + const currentPoints: number | undefined = cycle.bonusAnswer?.parts[partIndex].points ?? 0; + + cycle.setBonusPartAnswer(partIndex, teamName, currentPoints > 0 ? 0 : bonus.parts[partIndex].value); + + event.preventDefault(); + event.stopPropagation(); + break; default: break; } diff --git a/src/components/PacketLoader.tsx b/src/components/PacketLoader.tsx index eb89864..4e8844e 100644 --- a/src/components/PacketLoader.tsx +++ b/src/components/PacketLoader.tsx @@ -12,8 +12,8 @@ import { Label, Stack, StackItem } from "@fluentui/react"; export const PacketLoader = observer(function PacketLoader(props: IPacketLoaderProps): JSX.Element | null { const onLoadHandler = React.useCallback((ev: ProgressEvent) => onLoad(ev, props), [props]); const uploadHandler = React.useCallback( - (event: React.ChangeEvent, files: FileList | undefined | null) => { - onChange(props, files, onLoadHandler, event); + (event: React.ChangeEvent, file: File) => { + onChange(props, file, onLoadHandler, event); }, [props, onLoadHandler] ); @@ -49,22 +49,17 @@ export const PacketLoader = observer(function PacketLoader(props: IPacketLoaderP function onChange( props: IPacketLoaderProps, - files: FileList | undefined | null, + file: File, onLoadHandler: (ev: ProgressEvent) => void, - event: React.ChangeEvent + event: React.ChangeEvent | React.DragEvent ): void { event.preventDefault(); props.appState.uiState.clearPacketStatus(); - if (files == undefined || files.length === 0) { - return; - } - const fileReader = new FileReader(); fileReader.onload = onLoadHandler; // docx files should be read as a binaray, while json should be read as text - const file: File = files[0]; props.appState.uiState.setPacketFilename(file.name); if (file.type === "application/json" || file.type === "text/plain") { diff --git a/src/components/dialogs/ImportFromQBJDialog.tsx b/src/components/dialogs/ImportFromQBJDialog.tsx index c5c8d0a..a4972e8 100644 --- a/src/components/dialogs/ImportFromQBJDialog.tsx +++ b/src/components/dialogs/ImportFromQBJDialog.tsx @@ -124,6 +124,6 @@ function onPivotLinkClick(item: PivotItem | undefined): void { ImportFromQBJDialogController.onPivotChange(pivotKey); } -function onQBJFileChange(event: React.ChangeEvent, fileList: FileList | null | undefined): void { - ImportFromQBJDialogController.onQBJFileChange(fileList); +function onQBJFileChange(event: React.ChangeEvent, file: File): void { + ImportFromQBJDialogController.onQBJFileChange(file); } diff --git a/src/components/dialogs/ImportFromQBJDialogController.ts b/src/components/dialogs/ImportFromQBJDialogController.ts index 449cd8c..048baf5 100644 --- a/src/components/dialogs/ImportFromQBJDialogController.ts +++ b/src/components/dialogs/ImportFromQBJDialogController.ts @@ -51,16 +51,7 @@ export function onPivotChange(pivotKey: ImportFromQBJPivotKey): void { AppState.instance.uiState.dialogState.importFromQBJDialog?.setPivotKey(pivotKey); } -export function onQBJFileChange(fileList: FileList | null | undefined): void { - if (fileList == undefined) { - return; - } - - const file: File | undefined = fileList[0]; - if (file == undefined) { - return; - } - +export function onQBJFileChange(file: File): void { const fileReader = new FileReader(); fileReader.onload = onLoadQBJ; diff --git a/src/components/dialogs/ImportGameDialog.tsx b/src/components/dialogs/ImportGameDialog.tsx index 307d3d2..95b01f1 100644 --- a/src/components/dialogs/ImportGameDialog.tsx +++ b/src/components/dialogs/ImportGameDialog.tsx @@ -53,8 +53,7 @@ const ImportGameDialogBody = observer(function ImportGameDialogBody(): JSX.Eleme [appState] ); const changeHandler = React.useCallback( - (event: React.ChangeEvent, fileList: FileList | null | undefined) => - onFilePickerChange(appState, fileList, loadHandler), + (event: React.ChangeEvent, file: File) => onFilePickerChange(appState, file, loadHandler), [appState, loadHandler] ); @@ -92,15 +91,9 @@ const ImportGameDialogBody = observer(function ImportGameDialogBody(): JSX.Eleme function onFilePickerChange( appState: AppState, - fileList: FileList | null | undefined, + file: File, onLoadHandler: (ev: ProgressEvent) => void ): void { - if (fileList == undefined) { - return; - } - - const file: File = fileList[0]; - const fileReader = new FileReader(); fileReader.onload = onLoadHandler; diff --git a/src/components/dialogs/NewGameDialog.tsx b/src/components/dialogs/NewGameDialog.tsx index 1721dd5..44cd30b 100644 --- a/src/components/dialogs/NewGameDialog.tsx +++ b/src/components/dialogs/NewGameDialog.tsx @@ -16,6 +16,8 @@ import { ITextFieldStyles, assertNever, ThemeContext, + Link, + IStackItemStyles, } from "@fluentui/react"; import * as NewGameValidator from "../../state/NewGameValidator"; @@ -88,6 +90,8 @@ const modalProps: IModalProps = { const rostersInputStyles: Partial = { root: { marginRight: 10, width: "75%" } }; +const rosterFileLinkStyles: IStackItemStyles = { root: { marginBottom: 10 } }; + export const NewGameDialog = observer(function NewGameDialog(): JSX.Element { const appState: AppState = React.useContext(StateContext); const submitHandler = React.useCallback(() => onSubmit(appState), [appState]); @@ -438,20 +442,14 @@ const FromQBJRegistrationNewGameBody = observer(function FromQBJRegistrationNewG const uiState: UIState = props.appState.uiState; const loadHandler = React.useCallback( - (event: React.ChangeEvent, fileList: FileList | null | undefined) => { + (event: React.ChangeEvent, file: File) => { if ( - fileList == undefined || uiState.pendingNewGame == undefined || uiState.pendingNewGame.type !== PendingGameType.QBJRegistration ) { return; } - const file: File | null = fileList.item(0); - if (file === null) { - return; - } - uiState.clearPendingNewGameRegistrationStatus(); file.text() @@ -511,6 +509,11 @@ const FromQBJRegistrationNewGameBody = observer(function FromQBJRegistrationNewG {(theme) => ( + + + Create registration file + +