aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/collections/FlashcardPracticeUI.tsx
diff options
context:
space:
mode:
authorNathan-SR <144961007+Nathan-SR@users.noreply.github.com>2025-03-11 17:43:05 +0100
committerNathan-SR <144961007+Nathan-SR@users.noreply.github.com>2025-03-11 17:43:05 +0100
commitfa937182bc93aa2c6faadda80ea998cdfd479b4e (patch)
treecba8e16edcccc6fd2932173484ac444cb79abea2 /src/client/views/collections/FlashcardPracticeUI.tsx
parentcf91c46cfec6e3e36b9184764016f9c1b5c997d4 (diff)
parent04669ffeb163688c7aefd7b5face7998252abdca (diff)
Merge branch 'master' of https://github.com/brown-dash/Dash-Web into DocCreatorMenu-work
Diffstat (limited to 'src/client/views/collections/FlashcardPracticeUI.tsx')
-rw-r--r--src/client/views/collections/FlashcardPracticeUI.tsx214
1 files changed, 214 insertions, 0 deletions
diff --git a/src/client/views/collections/FlashcardPracticeUI.tsx b/src/client/views/collections/FlashcardPracticeUI.tsx
new file mode 100644
index 000000000..c071c5fb8
--- /dev/null
+++ b/src/client/views/collections/FlashcardPracticeUI.tsx
@@ -0,0 +1,214 @@
+import { IconProp } from '@fortawesome/fontawesome-svg-core';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { Tooltip } from '@mui/material';
+import { MultiToggle, Type } from '@dash/components';
+import { computed, makeObservable } from 'mobx';
+import { observer } from 'mobx-react';
+import * as React from 'react';
+import { returnFalse, returnZero, setupMoveUpEvents } from '../../../ClientUtils';
+import { emptyFunction } from '../../../Utils';
+import { Doc, DocListCast } from '../../../fields/Doc';
+import { BoolCast, NumCast, StrCast } from '../../../fields/Types';
+import { SnappingManager } from '../../util/SnappingManager';
+import { Transform } from '../../util/Transform';
+import { ObservableReactComponent } from '../ObservableReactComponent';
+import { DocumentView, DocumentViewProps } from '../nodes/DocumentView';
+import './FlashcardPracticeUI.scss';
+
+export enum practiceMode {
+ PRACTICE = 'practice',
+ QUIZ = 'quiz',
+}
+enum practiceVal {
+ MISSED = 'missed',
+ CORRECT = 'correct',
+}
+
+export enum flashcardRevealOp {
+ FLIP = 'flip',
+ SLIDE = 'slide',
+}
+
+interface PracticeUIProps {
+ fieldKey: string;
+ layoutDoc: Doc;
+ filteredChildDocs: () => Doc[];
+ allChildDocs: () => Doc[];
+ curDoc: () => Doc | undefined;
+ advance?: (correct: boolean) => void;
+ renderDepth: number;
+ sideBtnWidth: number;
+ uiBtnScaling: number;
+ ScreenToLocalBoxXf: () => Transform;
+ docViewProps: () => DocumentViewProps;
+ setFilterFunc: (func?: (doc: Doc) => boolean) => void;
+}
+@observer
+export class FlashcardPracticeUI extends ObservableReactComponent<PracticeUIProps> {
+ constructor(props: PracticeUIProps) {
+ super(props);
+ makeObservable(this);
+ this._props.setFilterFunc(this.tryFilterOut);
+ }
+
+ componentWillUnmount(): void {
+ this._props.setFilterFunc(undefined);
+ }
+
+ get practiceField() { return this._props.fieldKey + "_practice"; } // prettier-ignore
+
+ @computed get filterDoc() { return DocListCast(Doc.MyContextMenuBtns.data).find(doc => doc.title === 'Filter'); } // prettier-ignore
+ @computed get practiceMode() { return this._props.allChildDocs().some(doc => doc._layout_flashcardType) ? StrCast(this._props.layoutDoc.practiceMode) : ''; } // prettier-ignore
+
+ btnHeight = () => NumCast(this.filterDoc?.height) * Math.min(1, this._props.ScreenToLocalBoxXf().Scale);
+ btnWidth = () => (!this.filterDoc ? 1 : (this.btnHeight() * NumCast(this.filterDoc._width)) / NumCast(this.filterDoc._height));
+
+ /**
+ * Sets the practice mode answer style for flashcards
+ * @param mode practiceMode or undefined for no practice
+ */
+ setPracticeMode = (mode: practiceMode | undefined) => {
+ this._props.layoutDoc.practiceMode = mode;
+ this._props.allChildDocs().map(doc => (doc[this.practiceField] = undefined));
+ };
+
+ @computed get emptyMessage() {
+ const cardCount = this._props.filteredChildDocs().length;
+ const practiceMessage = this.practiceMode && !Doc.hasDocFilter(this._props.layoutDoc, 'tags', Doc.FilterAny) && !cardCount ? 'Finished! Click here to view all flashcards.' : '';
+ const filterMessage = practiceMessage
+ ? ''
+ : Doc.hasDocFilter(this._props.layoutDoc, 'tags', Doc.FilterAny) && !cardCount
+ ? 'No tagged items. Click here to view all flash cards.'
+ : this.practiceMode && !cardCount
+ ? 'No flashcards to show! Click here to leave practice mode'
+ : '';
+ return !practiceMessage && !filterMessage ? null : (
+ <p
+ className="FlashcardPracticeUI-message"
+ style={{ transform: `scale(${this._props.uiBtnScaling})` }}
+ onClick={() => {
+ if (filterMessage || practiceMessage) {
+ this.setPracticeMode(undefined);
+ Doc.setDocFilter(this._props.layoutDoc, 'tags', Doc.FilterAny, 'remove');
+ }
+ }}>
+ {filterMessage || practiceMessage}
+ </p>
+ );
+ }
+
+ @computed get practiceButtons() {
+ /*
+ * Sets a flashcard to either missed or correct depending on if they got the question right in practice mode.
+ */
+ const setPracticeVal = (e: React.MouseEvent, val: string) => {
+ e.stopPropagation();
+ const curDoc = this._props.curDoc();
+ this._props.advance?.(val === practiceVal.CORRECT);
+ curDoc && (curDoc[this.practiceField] = val);
+ };
+
+ return this.practiceMode == practiceMode.PRACTICE && this._props.curDoc() ? (
+ <div className="FlashcardPracticeUI-practice" style={{ transform: `scale(${this._props.uiBtnScaling})`, bottom: `${this._props.sideBtnWidth}px`, height: `${this._props.sideBtnWidth}px` }}>
+ <Tooltip title="Incorrect. View again later.">
+ <div key="remove" className="FlashcardPracticeUI-remove" onPointerDown={e => setupMoveUpEvents(this, e, returnFalse, emptyFunction, () => setPracticeVal(e, practiceVal.MISSED))}>
+ <FontAwesomeIcon icon="xmark" color="red" size="1x" />
+ </div>
+ </Tooltip>
+ <Tooltip title="Correct">
+ <div key="check" className="FlashcardPracticeUI-check" onPointerDown={e => setupMoveUpEvents(this, e, returnFalse, emptyFunction, () => setPracticeVal(e, practiceVal.CORRECT))}>
+ <FontAwesomeIcon icon="check" color="green" size="1x" />
+ </div>
+ </Tooltip>
+ </div>
+ ) : null;
+ }
+ @computed get practiceModesMenu() {
+ const setColor = (mode: practiceMode) => (StrCast(this.practiceMode) === mode ? 'white' : 'lightgray');
+ const togglePracticeMode = (mode: practiceMode) => this.setPracticeMode(mode === this.practiceMode ? undefined : mode);
+
+ return !this._props.allChildDocs().some(doc => doc._layout_flashcardType) ? null : (
+ <div
+ className="FlashcardPracticeUI-practiceModes"
+ style={{
+ transform: this._props.ScreenToLocalBoxXf().Scale >= 1 ? undefined : `translateY(${this.btnHeight() / this._props.ScreenToLocalBoxXf().Scale - this.btnHeight()}px)`,
+ }}>
+ <MultiToggle
+ tooltip="Practice flashcards one at a time"
+ type={Type.PRIM}
+ color={SnappingManager.userColor}
+ background={SnappingManager.userVariantColor}
+ multiSelect={false}
+ isToggle={false}
+ toggleStatus={!!this.practiceMode}
+ label="Practice"
+ items={[
+ [practiceMode.QUIZ, 'file-pen', 'Practice flashcards using GPT'],
+ [practiceMode.PRACTICE, 'check', this.practiceMode === practiceMode.PRACTICE ? 'Exit practice mode' : 'Practice flashcards manually'],
+ ].map(([item, icon, tooltip]) => ({
+ icon: <FontAwesomeIcon className={`FlashcardPracticeUI-${item}`} color={setColor(item as practiceMode)} icon={icon as IconProp} size="sm" />,
+ tooltip: tooltip,
+ val: item,
+ }))}
+ selectedItems={this.practiceMode}
+ onSelectionChange={(val: (string | number) | (string | number)[]) => togglePracticeMode(val as practiceMode)}
+ />
+ <MultiToggle
+ tooltip="How to reveal flashcard answer"
+ type={Type.PRIM}
+ color={SnappingManager.userColor}
+ background={SnappingManager.userVariantColor}
+ multiSelect={false}
+ isToggle={false}
+ toggleStatus={!!this.practiceMode}
+ label={StrCast(this._props.layoutDoc.revealOp, flashcardRevealOp.FLIP)}
+ items={[
+ ['reveal', StrCast(this._props.layoutDoc.revealOp) === flashcardRevealOp.SLIDE ? 'expand' : 'question', StrCast(this._props.layoutDoc.revealOp, flashcardRevealOp.FLIP)],
+ ['trigger', this._props.layoutDoc.revealOp_hover ? 'hand-point-up' : 'hand', this._props.layoutDoc.revealOp_hover ? 'show on hover' : 'show on click'],
+ ].map(([item, icon, tooltip]) => ({
+ icon: <FontAwesomeIcon className={`FlashcardPracticeUI-${item}`} color={setColor(item as practiceMode)} icon={icon as IconProp} size="sm" />,
+ tooltip: tooltip,
+ val: item,
+ }))}
+ selectedItems={this._props.layoutDoc.revealOp_hover ? ['reveal', 'trigger'] : 'reveal'}
+ onSelectionChange={(val: (string | number) | (string | number)[]) => {
+ if (val === 'reveal') this._props.layoutDoc.revealOp = this._props.layoutDoc.revealOp === flashcardRevealOp.SLIDE ? flashcardRevealOp.FLIP : flashcardRevealOp.SLIDE;
+ if (val === 'trigger') this._props.layoutDoc.revealOp_hover = !this._props.layoutDoc.revealOp_hover;
+ }}
+ />
+ </div>
+ );
+ }
+ tryFilterOut = (doc: Doc) => (this.practiceMode && doc?._layout_flashcardType && doc[this.practiceField] === practiceVal.CORRECT ? true : false); // show only cards that aren't marked as correct
+ render() {
+ return (
+ <div className="FlashcardPracticeUI">
+ {this.emptyMessage}
+ {this.practiceButtons}
+ {this._props.layoutDoc._chromeHidden ? null : (
+ <div className="FlashcardPracticeUI-menu" style={{ height: this.btnHeight(), width: this.btnHeight(), transform: `scale(${this._props.uiBtnScaling})` }}>
+ {!this.filterDoc ? null : (
+ <DocumentView
+ {...this._props.docViewProps()}
+ Document={this.filterDoc}
+ TemplateDataDocument={undefined}
+ PanelWidth={this.btnWidth}
+ PanelHeight={this.btnHeight}
+ NativeWidth={returnZero}
+ NativeHeight={returnZero}
+ hideDecorations={BoolCast(this._props.layoutDoc.layout_hideDecorations)}
+ hideCaptions={true}
+ hideFilterStatus={true}
+ renderDepth={this._props.renderDepth + 1}
+ fitWidth={undefined}
+ showTags={false}
+ setContentViewBox={undefined}
+ />
+ )}
+ {this.practiceModesMenu}
+ </div>
+ )}
+ </div>
+ );
+ }
+}