385 lines
16 KiB
TypeScript
385 lines
16 KiB
TypeScript
import * as React from "react";
|
|
|
|
import Card from "@material-ui/core/Card";
|
|
import CardContent from "@material-ui/core/CardContent";
|
|
import TextField from "@material-ui/core/TextField";
|
|
import Grid from "@material-ui/core/Grid";
|
|
import Button from "@material-ui/core/Button";
|
|
import Typography from "@material-ui/core/Typography";
|
|
import Popover from "@material-ui/core/Popover";
|
|
import LinearProgress from "@material-ui/core/LinearProgress";
|
|
import CircularProgress from "@material-ui/core/CircularProgress";
|
|
import Paper from "@material-ui/core/Paper";
|
|
import Tooltip from "@material-ui/core/Tooltip";
|
|
import Dialog from "@material-ui/core/Dialog";
|
|
import DialogActions from "@material-ui/core/DialogActions";
|
|
import DialogContent from "@material-ui/core/DialogContent";
|
|
import DialogContentText from "@material-ui/core/DialogContentText";
|
|
import DialogTitle from "@material-ui/core/DialogTitle";
|
|
|
|
import CloseIcon from "@material-ui/icons/Close";
|
|
|
|
import { withRouter } from "react-router-dom";
|
|
|
|
import { IVocab, IReviewCard, vocabToReviewCard, reviewQTypeToStr } from "../models/vocab";
|
|
import { ReviewType, IReviewMetadata } from "../models/review";
|
|
|
|
import { levW } from "../algorithms/levenshtein";
|
|
import { LEVENSHTEIN_MAX_DISTANCE, MAX_ERROR_THRESHOLD } from "../config";
|
|
|
|
import { Queue } from "../utils/queue";
|
|
|
|
interface IProps {
|
|
levelId?: number;
|
|
vocabByLevel?: (level: number) => Promise<IVocab[]>;
|
|
vocabByQueue?: () => Promise<IVocab[]>;
|
|
setLastReview: (meta: IReviewMetadata, sm2: any, delta: number) => void;
|
|
reviewType: ReviewType;
|
|
|
|
history: any;
|
|
|
|
dialogOpen: boolean;
|
|
loading: boolean;
|
|
vocab: IVocab[];
|
|
current: IReviewCard;
|
|
metadata: IReviewMetadata;
|
|
popoverOpen: boolean;
|
|
popoverText: string;
|
|
popoverColor: string;
|
|
popoverTextColor: string;
|
|
|
|
setReviewDialog: (state: boolean) => void;
|
|
setSummary: (state: boolean) => void;
|
|
setPopover: (state: boolean, text: string, color: string, textColor: string) => void;
|
|
drawerButtonState: (state: boolean) => void;
|
|
setReview: (curent: IReviewCard, meta: IReviewMetadata) => void;
|
|
setLoading: (state: boolean) => void;
|
|
}
|
|
|
|
const ReviewPageWithRouter = withRouter(
|
|
class ReviewPage extends React.Component<IProps> {
|
|
private vocab: IVocab[] = [];
|
|
private reviewQueue: Queue<IReviewCard> = new Queue();
|
|
// Used for positioning the popover
|
|
private buttonRef: HTMLButtonElement;
|
|
private inputRef: HTMLInputElement;
|
|
// How often a group has been wrongly answered
|
|
// (Mapping: Vocab Id -> amount of times it was wrongly answered)
|
|
private error_data = {};
|
|
// Mapping: Vocab Id -> Correctly answered
|
|
private sm2_metadata: any = {};
|
|
private score_delta = 0;
|
|
|
|
componentDidMount() {
|
|
// Hide the drawer button
|
|
this.props.drawerButtonState(false);
|
|
|
|
// Show a loading spinner
|
|
this.props.setLoading(true);
|
|
|
|
// Get the correct vocabulary
|
|
const { reviewType, vocabByLevel, levelId, vocabByQueue } = this.props;
|
|
|
|
// Just to make TSC shut up
|
|
const noopPromise = () => {
|
|
return new Promise<IVocab[]>((res, rej) => {
|
|
rej([]);
|
|
});
|
|
};
|
|
const vocabByLevelW = vocabByLevel || noopPromise;
|
|
const vocabByQueueW = vocabByQueue || noopPromise;
|
|
const getVocab = {
|
|
[ReviewType.LEVEL]: () => vocabByLevelW(levelId),
|
|
[ReviewType.QUEUE]: () => vocabByQueueW(),
|
|
}[reviewType];
|
|
|
|
getVocab().then((res: IVocab[]) => {
|
|
// Stop the loading
|
|
this.props.setLoading(false);
|
|
this.vocab = res;
|
|
|
|
// Convert the vocab items into review queue cards
|
|
res.forEach(vocab => {
|
|
// Set the error data for the group
|
|
this.error_data[vocab.id] = 0;
|
|
|
|
vocabToReviewCard(vocab).forEach(this.reviewQueue.enqueue);
|
|
});
|
|
|
|
// Set the initial vocabulary card
|
|
this.props.setReview(this.reviewQueue.dequeue(), {
|
|
correct: 0,
|
|
wrong: 0,
|
|
});
|
|
});
|
|
}
|
|
|
|
openDialog = () => {
|
|
this.props.setReviewDialog(true);
|
|
}
|
|
closeDialog = () => {
|
|
this.props.setReviewDialog(false);
|
|
}
|
|
|
|
cancelReview = () => {
|
|
this.closeDialog();
|
|
|
|
// Show the drawer button again
|
|
this.props.drawerButtonState(true);
|
|
|
|
this.props.history.push("/dashboard");
|
|
}
|
|
|
|
increaseMeta = (correct: number, wrong: number): IReviewMetadata => {
|
|
const { metadata } = this.props;
|
|
|
|
return {
|
|
wrong: metadata.wrong + wrong,
|
|
correct: metadata.correct + correct,
|
|
};
|
|
}
|
|
|
|
vocabFromId = (id: number): IVocab | {} => {
|
|
return this.vocab.find((el) => el.id === this.props.current.id);
|
|
}
|
|
|
|
// When a vocabulary item has been answered, we need to update
|
|
// the group's SuperMemo2-data.
|
|
// We update based on the following State-Machine: (C = Correct; W = Wrong)
|
|
// Answer | Group | New group
|
|
// | | state
|
|
// -------+-------+----------
|
|
// C | C | C
|
|
// C | W | W
|
|
// W | C | W
|
|
// W | W | W
|
|
// (1)C | - | C
|
|
// (2)W | - | W
|
|
// Args:
|
|
// @correct: Was the answer given correct?
|
|
updateGroupSM2 = (correct: boolean) => {
|
|
const { id } = this.props.current;
|
|
switch (correct) {
|
|
case true:
|
|
// Case (1)
|
|
if (!(id in this.sm2_metadata)) {
|
|
this.sm2_metadata[id] = true;
|
|
break;
|
|
}
|
|
|
|
switch (this.sm2_metadata[id]) {
|
|
case true:
|
|
this.sm2_metadata[id] = true;
|
|
break;
|
|
case false:
|
|
this.sm2_metadata[id] = false;
|
|
break;
|
|
}
|
|
break;
|
|
case false:
|
|
// We don't need to explicitly catch case (2), as we set
|
|
// anything, that was incorrectly answered to false.
|
|
this.sm2_metadata[id] = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
checkInput = () => {
|
|
// Check if the given answer is somewhere in the german words
|
|
const input = this.inputRef.value || "";
|
|
|
|
const { current } = this.props;
|
|
|
|
// Map all possible answers to lowercase ( => ignore casing)
|
|
const answers = current.answers.map((el) => el.toLowerCase());
|
|
// Calculate the distances to all possible answers
|
|
const dists = answers.map((el) => levW(input.toLowerCase(), el));
|
|
// Find the lowest distance
|
|
const minDist = Math.min(...dists);
|
|
|
|
// Check if the user's answer was correct
|
|
if (minDist === 0) {
|
|
this.updateGroupSM2(true);
|
|
// TODO: Is this the correct amount of points?
|
|
this.score_delta += 1;
|
|
|
|
// Empty the input field
|
|
this.inputRef.value = "";
|
|
|
|
// TODO: Show it's correct?
|
|
// Show the next vocab word
|
|
if (this.reviewQueue.size() === 0) {
|
|
// Update the metadata
|
|
const newMeta = this.increaseMeta(1, 0);
|
|
this.props.setReview(current, newMeta);
|
|
this.props.setLastReview(newMeta, this.sm2_metadata, this.score_delta);
|
|
this.props.setLoading(true);
|
|
|
|
// Show the drawer button again
|
|
this.props.drawerButtonState(true);
|
|
|
|
// Go to the summary screen
|
|
this.props.history.push("/review/summary");
|
|
} else {
|
|
// Update the metadata
|
|
this.props.setReview(this.reviewQueue.dequeue(), this.increaseMeta(1, 0));
|
|
}
|
|
} else if (minDist <= LEVENSHTEIN_MAX_DISTANCE) {
|
|
this.props.setPopover(true, "Das war fast richtig", "yellow", "black");
|
|
} else {
|
|
// Update the metadata
|
|
this.updateGroupSM2(false);
|
|
|
|
// Update the error data
|
|
this.error_data[current.id] += 1;
|
|
if (this.error_data[current.id] <= MAX_ERROR_THRESHOLD) {
|
|
// Read the vocabulary group to the queue
|
|
// Find the vocabulary item
|
|
// TODO: if(!vocab)
|
|
const vocab = this.vocab.find(el => el.id === current.id);
|
|
|
|
// Re-add the vocabulary group
|
|
// NOTE: We don't need to force a re-render, as the state
|
|
// will be updated since we need to show the popover.
|
|
vocabToReviewCard(vocab).forEach(this.reviewQueue.enqueue);
|
|
}
|
|
|
|
// TODO: Is this the correct amount
|
|
this.score_delta -= 1;
|
|
this.props.setReview(this.props.current, this.increaseMeta(0, 1));
|
|
this.props.setPopover(true, "Das war nicht richtig", "red", "white");
|
|
}
|
|
}
|
|
|
|
render() {
|
|
if (this.props.loading) {
|
|
return <div>
|
|
<Grid
|
|
container
|
|
spacing={0}
|
|
direction="column"
|
|
alignItems="center"
|
|
justify="center"
|
|
style={{ minHeight: '100vh' }}>
|
|
<Grid item xs={12}>
|
|
<Paper className="paper">
|
|
<Grid container direction="column" spacing={8}>
|
|
<CircularProgress />
|
|
</Grid>
|
|
</Paper>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
</div>;
|
|
}
|
|
|
|
const { question, qtype } = this.props.current;
|
|
const questionTitle = `${question} (${reviewQTypeToStr(qtype)})`;
|
|
// TODO/NOTE: This assumes that each vocabulary item gets mapped to
|
|
// exactly 3 review cards
|
|
// NOTE: The 'numCards === 0 ?' is neccessary as (for some reasdn) numCards
|
|
// starts of by being 0, which results in progress === -Inifinity.
|
|
// That looks weird as the user sees the progressbar jump.
|
|
const numCards = this.vocab.length * 3;
|
|
const progress = numCards === 0 ? (
|
|
0
|
|
) : (
|
|
100 * (numCards - (this.reviewQueue.size() + 1)) / numCards
|
|
);
|
|
|
|
return <div>
|
|
<Grid container justify="center">
|
|
<Grid item style={{ width: "100%" }}>
|
|
<Card>
|
|
<CardContent>
|
|
<Grid container direction="column">
|
|
<center>
|
|
<Typography variant="display2">
|
|
{questionTitle}
|
|
</Typography>
|
|
</center>
|
|
<TextField
|
|
margin="normal"
|
|
fullWidth={true}
|
|
inputRef={node => this.inputRef = node}
|
|
onKeyPress={(ev) => {
|
|
// Allow checking of the answer by pressing Enter
|
|
if (ev.key === "Enter")
|
|
this.checkInput();
|
|
}} />
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={progress} />
|
|
<Popover
|
|
open={this.props.popoverOpen}
|
|
anchorOrigin={{
|
|
vertical: "center",
|
|
horizontal: "center"
|
|
}}
|
|
transformOrigin={{
|
|
vertical: "bottom",
|
|
horizontal: "center"
|
|
}}
|
|
anchorEl={this.buttonRef}
|
|
onClose={() => this.props.setPopover(false, "", "", "")}
|
|
PaperProps={{
|
|
style: {
|
|
backgroundColor: this.props.popoverColor,
|
|
padding: 10,
|
|
color: this.props.popoverTextColor,
|
|
}
|
|
}}>
|
|
<Typography
|
|
variant="button"
|
|
color="inherit">
|
|
{this.props.popoverText}
|
|
</Typography>
|
|
</Popover>
|
|
<Button
|
|
onClick={this.checkInput}
|
|
buttonRef={node => this.buttonRef = node}>
|
|
Prüfen
|
|
</Button>
|
|
</Grid>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
</Grid>
|
|
<Tooltip
|
|
title="Abbrechen"
|
|
placement="top">
|
|
<Button
|
|
variant="fab"
|
|
color="primary"
|
|
className="review-fab"
|
|
onClick={this.openDialog}>
|
|
<CloseIcon />
|
|
</Button>
|
|
</Tooltip>
|
|
<Dialog
|
|
open={this.props.dialogOpen}
|
|
onClose={this.closeDialog}>
|
|
<DialogTitle>Willst du die Wiederholung abbrechen?</DialogTitle>
|
|
<DialogContent>
|
|
<DialogContentText>
|
|
Wenn du jetzt abbricht, dann geht dein in dieser Wiederholung gesammelte Fortschritt
|
|
verloren.
|
|
</DialogContentText>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button
|
|
onClick={this.closeDialog}>
|
|
Zurück zur Wiederholung
|
|
</Button>
|
|
<Button
|
|
onClick={this.cancelReview}>
|
|
Wiederholung abbrechen
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</div>;
|
|
}
|
|
}
|
|
);
|
|
export default ReviewPageWithRouter;
|