Skip to content

Commit

Permalink
Merged PR 317: v1.33.0 - bonus shortcuts, drag+drop (#309)
Browse files Browse the repository at this point in the history
- Add shortcut to toggle bonus parts with the 1-5 keys
- Add support for drag+drop for loading files (packets, QBJ files, etc.)
- Add link to a webpage that lets you create QBJ registration files
- Fix issue where shortcuts could be triggered when a dialog was opened
- Bump version to 1.33.0
  • Loading branch information
alopezlago authored Sep 3, 2024
1 parent e0acfcc commit ea822d4
Show file tree
Hide file tree
Showing 8 changed files with 140 additions and 59 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
86 changes: 66 additions & 20 deletions src/components/FilePicker.tsx
Original file line number Diff line number Diff line change
@@ -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<IFilePickerProps>): 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<boolean>(false);

const fileInput: React.MutableRefObject<HTMLInputElement | null> = React.useRef<HTMLInputElement | null>(null);
const changeHandler = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLElement>) => {
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]
);
Expand All @@ -16,24 +49,37 @@ export const FilePicker = observer(function FilePicker(props: React.PropsWithChi
<Label required={props.required}>{props.label}</Label>
);

// The border can make the border a little jumpy. A low priority issue for now.
const stackStyles: IStackStyles | undefined = fileDraggedOver ? fileDraggedOverStyle : undefined;

return (
<>
{label}
<input
type="file"
className={classNames.fileInput}
accept={props.accept}
ref={fileInput}
onChange={changeHandler}
/>
<DefaultButton
text={props.buttonText}
onClick={() => {
fileInput.current?.click();
}}
/>
{props.children}
</>
<Stack
onDrop={dropHandler}
onDragOver={(ev) => {
setFileDraggedOver(true);
ev.preventDefault();
}}
onDragLeave={() => setFileDraggedOver(false)}
styles={stackStyles}
>
<StackItem>{label}</StackItem>
<StackItem>
<input
type="file"
className={classNames.fileInput}
accept={props.accept}
ref={fileInput}
onChange={changeHandler}
/>
<DefaultButton
text={props.buttonText}
onClick={() => {
fileInput.current?.click();
}}
/>
{props.children}
</StackItem>
</Stack>
);
});

Expand All @@ -43,7 +89,7 @@ export interface IFilePickerProps {
label?: string;
required?: boolean;

onChange(event: React.ChangeEvent<HTMLInputElement>, fileList: FileList | null | undefined): void;
onChange(event: React.SyntheticEvent, file: File): void;
}

interface IFileInputClassNames {
Expand Down
55 changes: 54 additions & 1 deletion src/components/ModaqControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
Expand Down
13 changes: 4 additions & 9 deletions src/components/PacketLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileReader>) => onLoad(ev, props), [props]);
const uploadHandler = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>, files: FileList | undefined | null) => {
onChange(props, files, onLoadHandler, event);
(event: React.ChangeEvent<HTMLInputElement>, file: File) => {
onChange(props, file, onLoadHandler, event);
},
[props, onLoadHandler]
);
Expand Down Expand Up @@ -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<FileReader>) => void,
event: React.ChangeEvent<HTMLInputElement>
event: React.ChangeEvent<HTMLInputElement> | 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") {
Expand Down
4 changes: 2 additions & 2 deletions src/components/dialogs/ImportFromQBJDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,6 @@ function onPivotLinkClick(item: PivotItem | undefined): void {
ImportFromQBJDialogController.onPivotChange(pivotKey);
}

function onQBJFileChange(event: React.ChangeEvent<HTMLInputElement>, fileList: FileList | null | undefined): void {
ImportFromQBJDialogController.onQBJFileChange(fileList);
function onQBJFileChange(event: React.ChangeEvent<HTMLInputElement>, file: File): void {
ImportFromQBJDialogController.onQBJFileChange(file);
}
11 changes: 1 addition & 10 deletions src/components/dialogs/ImportFromQBJDialogController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
11 changes: 2 additions & 9 deletions src/components/dialogs/ImportGameDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@ const ImportGameDialogBody = observer(function ImportGameDialogBody(): JSX.Eleme
[appState]
);
const changeHandler = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>, fileList: FileList | null | undefined) =>
onFilePickerChange(appState, fileList, loadHandler),
(event: React.ChangeEvent<HTMLInputElement>, file: File) => onFilePickerChange(appState, file, loadHandler),
[appState, loadHandler]
);

Expand Down Expand Up @@ -92,15 +91,9 @@ const ImportGameDialogBody = observer(function ImportGameDialogBody(): JSX.Eleme

function onFilePickerChange(
appState: AppState,
fileList: FileList | null | undefined,
file: File,
onLoadHandler: (ev: ProgressEvent<FileReader>) => void
): void {
if (fileList == undefined) {
return;
}

const file: File = fileList[0];

const fileReader = new FileReader();
fileReader.onload = onLoadHandler;

Expand Down
17 changes: 10 additions & 7 deletions src/components/dialogs/NewGameDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
ITextFieldStyles,
assertNever,
ThemeContext,
Link,
IStackItemStyles,
} from "@fluentui/react";

import * as NewGameValidator from "../../state/NewGameValidator";
Expand Down Expand Up @@ -88,6 +90,8 @@ const modalProps: IModalProps = {

const rostersInputStyles: Partial<ITextFieldStyles> = { 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]);
Expand Down Expand Up @@ -438,20 +442,14 @@ const FromQBJRegistrationNewGameBody = observer(function FromQBJRegistrationNewG
const uiState: UIState = props.appState.uiState;

const loadHandler = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>, fileList: FileList | null | undefined) => {
(event: React.ChangeEvent<HTMLInputElement>, 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()
Expand Down Expand Up @@ -511,6 +509,11 @@ const FromQBJRegistrationNewGameBody = observer(function FromQBJRegistrationNewG
<ThemeContext.Consumer>
{(theme) => (
<Stack>
<StackItem styles={rosterFileLinkStyles}>
<Link href="https://www.quizbowlreader.com/createTournamentFile.html" target="_blank">
Create registration file
</Link>
</StackItem>
<StackItem>
<div className={props.classes.loadContainer}>
<FilePicker buttonText="Load Roster..." onChange={loadHandler} />
Expand Down

0 comments on commit ea822d4

Please sign in to comment.