refactor: MONOREPO
This commit is contained in:
137
frontend/src/actions/index.ts
Normal file
137
frontend/src/actions/index.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { IVocab } from "../models/vocab";
|
||||
import { IUser } from "../models/user";
|
||||
import { ILevel } from "../models/level";
|
||||
import { IReviewMetadata, IReviewCard } from "../models/review";
|
||||
|
||||
export const SET_DRAWER = "SET_DRAWER";
|
||||
export function setDrawer(state: boolean) {
|
||||
return {
|
||||
type: SET_DRAWER,
|
||||
show: state,
|
||||
};
|
||||
};
|
||||
|
||||
export const SET_DRAWER_BUTTON = "SET_DRAWER_BUTTON";
|
||||
export function setDrawerButton(state: boolean) {
|
||||
return {
|
||||
type: SET_DRAWER_BUTTON,
|
||||
show: state,
|
||||
};
|
||||
};
|
||||
|
||||
export const LOGIN_SET_SNACKBAR = "LOGIN_SET_SNACKBAR";
|
||||
export function setLoginSnackbar(visibility: boolean, msg: string = "") {
|
||||
return {
|
||||
type: LOGIN_SET_SNACKBAR,
|
||||
show: visibility,
|
||||
msg,
|
||||
};
|
||||
};
|
||||
|
||||
export const LOGIN_SET_LOADING = "LOGIN_SET_LOADING";
|
||||
export function setLoginLoading(visibility: boolean) {
|
||||
return {
|
||||
type: LOGIN_SET_LOADING,
|
||||
show: visibility,
|
||||
};
|
||||
};
|
||||
|
||||
export const SET_AUTHENTICATED = "SET_AUTHENTICATED";
|
||||
export function setAuthenticated(state: boolean) {
|
||||
return {
|
||||
type: SET_AUTHENTICATED,
|
||||
status: state,
|
||||
};
|
||||
}
|
||||
|
||||
export const SET_USER = "SET_USER";
|
||||
export function setUser(user: IUser) {
|
||||
return {
|
||||
type: SET_USER,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
export const LEVEL_SET_LOOKEDAT = "LEVEL_SET_LOOKEDAT";
|
||||
export function setLevelLookedAt(ids: number[]) {
|
||||
return {
|
||||
type: LEVEL_SET_LOOKEDAT,
|
||||
lookedAt: ids,
|
||||
};
|
||||
};
|
||||
|
||||
export const LEVEL_SET_CUR_VOCAB = "LEVEL_SET_CUR_VOCAB";
|
||||
export function setLevelCurrentVocab(vocab: IVocab) {
|
||||
return {
|
||||
type: LEVEL_SET_CUR_VOCAB,
|
||||
vocab,
|
||||
};
|
||||
};
|
||||
|
||||
export const LEVEL_SET_VOCAB = "LEVEL_SET_VOCAB";
|
||||
export function setLevelVocab(vocab: IVocab[]) {
|
||||
return {
|
||||
type: LEVEL_SET_VOCAB,
|
||||
vocab,
|
||||
};
|
||||
};
|
||||
|
||||
export const LEVEL_SET_LOADING = "LEVEL_SET_LOADING";
|
||||
export function setLevelLoading(state: boolean) {
|
||||
return {
|
||||
type: LEVEL_SET_LOADING,
|
||||
state,
|
||||
};
|
||||
};
|
||||
|
||||
export const SET_LEVELS = "SET_LEVELS";
|
||||
export function setLevels(levels: ILevel[]) {
|
||||
return {
|
||||
type: SET_LEVELS,
|
||||
levels,
|
||||
};
|
||||
};
|
||||
|
||||
export const REVIEW_SET_POPOVER = "REVIEW_SET_POPOVER";
|
||||
export function setReviewPopover(state: boolean, text: string, color: string, textColor: string) {
|
||||
return {
|
||||
type: REVIEW_SET_POPOVER,
|
||||
state,
|
||||
text,
|
||||
color,
|
||||
textColor,
|
||||
};
|
||||
};
|
||||
|
||||
export const REVIEW_SET_LOADING = "REVIEW_SET_LOADING";
|
||||
export function setReviewLoading(state: boolean) {
|
||||
return {
|
||||
type: REVIEW_SET_LOADING,
|
||||
state,
|
||||
};
|
||||
};
|
||||
|
||||
export const SET_LAST_REVIEW = "SET_LAST_REVIEW";
|
||||
export function setLastReview(metadata: IReviewMetadata) {
|
||||
return {
|
||||
type: SET_LAST_REVIEW,
|
||||
metadata,
|
||||
};
|
||||
};
|
||||
|
||||
export const SET_REVIEW = "SET_REVIEW";
|
||||
export function setReview(current: IReviewCard, meta: IReviewMetadata) {
|
||||
return {
|
||||
type: SET_REVIEW,
|
||||
current,
|
||||
meta,
|
||||
};
|
||||
};
|
||||
|
||||
export const LEVELLIST_SET_LOADING = "LEVELLIST_SET_LOADING";
|
||||
export function setLevelListLoading(state: boolean) {
|
||||
return {
|
||||
type: LEVELLIST_SET_LOADING,
|
||||
state,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { lev, levW } from "../";
|
||||
|
||||
test("lev(abc, abc) == 0", () => {
|
||||
expect(lev("abc", "abc", 3, 3)).toBe(0);
|
||||
expect(levW("abc", "abc")).toBe(0);
|
||||
});
|
||||
|
||||
test("lev(abc, abd) == 1", () => {
|
||||
expect(lev("abc", "abd", 3, 3)).toBe(1);
|
||||
expect(levW("abc", "abd")).toBe(1);
|
||||
});
|
||||
|
||||
test("lev(abc, abcd) == 1", () => {
|
||||
expect(lev("abc", "abcd", 3, 4)).toBe(1);
|
||||
expect(levW("abc", "abcd")).toBe(1);
|
||||
});
|
||||
|
||||
test("lev(abcd, bcd) == 1", () => {
|
||||
expect(lev("abcd", "bcd", 4, 3)).toBe(1);
|
||||
expect(levW("abcd", "bcd")).toBe(1);
|
||||
});
|
||||
30
frontend/src/algorithms/levenshtein/index.ts
Normal file
30
frontend/src/algorithms/levenshtein/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
function one(a: string, b: string, i: number, j: number): number {
|
||||
if (a[i] === b[j]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Computes the difference between the strings a and b.
|
||||
// @a, b: The strings to compare
|
||||
// @i, j: From where to compare (a_i and b_j)
|
||||
// @ret : The distance in insertions, deletions and changes
|
||||
export function lev(a: string, b: string, i: number, j: number): number {
|
||||
if (Math.min(i, j) === 0) {
|
||||
return Math.max(i, j);
|
||||
} else {
|
||||
return Math.min(
|
||||
lev(a, b, i - 1, j) + 1,
|
||||
lev(a, b, i, j - 1) + 1,
|
||||
lev(a, b, i - 1, j - 1) + one(a, b, i, j)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Computes the difference between the strings a and b.
|
||||
// @a, b: The strings to compare
|
||||
// @ret : The distance in insertions, deletions and changes
|
||||
export function levW(a: string, b: string): number {
|
||||
return lev(a, b, a.length, b.length);
|
||||
}
|
||||
35
frontend/src/algorithms/sm2/index.ts
Normal file
35
frontend/src/algorithms/sm2/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { dayInNDays } from "../../utils/date";
|
||||
|
||||
export interface ISchedulingData {
|
||||
easiness: number;
|
||||
consecutiveCorrectAnswers: number;
|
||||
nextDueDate: number;
|
||||
};
|
||||
|
||||
export enum AnswerType {
|
||||
CORRECT,
|
||||
WRONG,
|
||||
};
|
||||
|
||||
function performanceRating(answer: AnswerType): number {
|
||||
switch (answer) {
|
||||
case AnswerType.WRONG:
|
||||
return 1;
|
||||
case AnswerType.CORRECT:
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSchedulingData(data: ISchedulingData, answer: AnswerType): ISchedulingData {
|
||||
const perfRating = performanceRating(answer);
|
||||
|
||||
data.easiness += -0.8 + 0.28 * perfRating + 0.02 * Math.pow(perfRating, 2);
|
||||
data.consecutiveCorrectAnswers = answer === AnswerType.CORRECT ? (
|
||||
data.consecutiveCorrectAnswers + 1
|
||||
) : 0;
|
||||
data.nextDueDate = answer === AnswerType.CORRECT ? (
|
||||
dayInNDays(6 * Math.pow(data.easiness, data.consecutiveCorrectAnswers - 1))
|
||||
) : dayInNDays(1);
|
||||
|
||||
return data;
|
||||
}
|
||||
158
frontend/src/components/Drawer.tsx
Normal file
158
frontend/src/components/Drawer.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import AppBar from "@material-ui/core/AppBar";
|
||||
import Toolbar from "@material-ui/core/Toolbar";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import IconButton from "@material-ui/core/IconButton";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import SwipeableDrawer from "@material-ui/core/SwipeableDrawer";
|
||||
import List from "@material-ui/core/List";
|
||||
import ListItem from "@material-ui/core/ListItem";
|
||||
import ListItemIcon from "@material-ui/core/ListItemIcon";
|
||||
import ListItemText from "@material-ui/core/ListItemText";
|
||||
import Divider from "@material-ui/core/Divider";
|
||||
import Avatar from "@material-ui/core/Avatar";
|
||||
import MenuIcon from "@material-ui/icons/Menu";
|
||||
import SettingsIcon from "@material-ui/icons/Settings";
|
||||
import PersonIcon from "@material-ui/icons/Person";
|
||||
import InfoIcon from "@material-ui/icons/Info";
|
||||
import HomeIcon from "@material-ui/icons/Home";
|
||||
import BookIcon from "@material-ui/icons/Book";
|
||||
import ViewWeekIcon from "@material-ui/icons/ViewWeek";
|
||||
|
||||
import { IUser } from "../models/user";
|
||||
|
||||
interface IProps {
|
||||
logout: () => void;
|
||||
|
||||
user: IUser;
|
||||
open: boolean;
|
||||
showButton: boolean;
|
||||
authenticated: boolean;
|
||||
setDrawer: (state: boolean) => void;
|
||||
};
|
||||
|
||||
export default class Drawer extends React.Component<IProps> {
|
||||
openDrawer = () => {
|
||||
this.props.setDrawer(true);
|
||||
}
|
||||
closeDrawer = () => {
|
||||
this.props.setDrawer(false);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
{
|
||||
(this.props.authenticated && this.props.showButton) ? (
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.openDrawer}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
) : undefined
|
||||
}
|
||||
<Typography className="flex" variant="title" color="inherit">
|
||||
Lateinicus
|
||||
</Typography>
|
||||
{
|
||||
this.props.authenticated ? (
|
||||
<Button color="inherit">
|
||||
{`${this.props.user.score} / 200`}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<SwipeableDrawer
|
||||
anchor="left"
|
||||
open={this.props.open}
|
||||
onClose={this.closeDrawer}
|
||||
onOpen={this.openDrawer}>
|
||||
<List component="nav">
|
||||
<ListItem>
|
||||
<Avatar alt="{Username}" style={{ width: 80, height: 80 }} src="https://avatarfiles.alphacoders.com/105/105250.jpg" />
|
||||
<ListItemText primary={"{{ PLACEHOLDER }}"} />
|
||||
</ListItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ListItem button>
|
||||
<ListItemIcon>
|
||||
<PersonIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Profil" />
|
||||
</ListItem>
|
||||
<ListItem button>
|
||||
<ListItemIcon>
|
||||
<SettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Einstellungen" />
|
||||
</ListItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ListItem
|
||||
component={Link}
|
||||
to="/dashboard"
|
||||
onClick={this.closeDrawer}
|
||||
button>
|
||||
<ListItemIcon>
|
||||
<HomeIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Dashboard" />
|
||||
</ListItem>
|
||||
<ListItem
|
||||
component={Link}
|
||||
to="/review/queue"
|
||||
onClick={this.closeDrawer}
|
||||
button>
|
||||
<ListItemIcon>
|
||||
<BookIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
Vokabeln üben
|
||||
</ListItemText>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
component={Link}
|
||||
to="/levelList"
|
||||
onClick={this.closeDrawer}
|
||||
button>
|
||||
<ListItemIcon>
|
||||
<ViewWeekIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
Levelübersicht
|
||||
</ListItemText>
|
||||
</ListItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ListItem button onClick={() => {
|
||||
this.closeDrawer();
|
||||
this.props.logout();
|
||||
}}>
|
||||
<ListItemText>
|
||||
Abmelden
|
||||
</ListItemText>
|
||||
</ListItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ListItem button onClick={() => window.location = "https://gitlab.com/Polynomdivision/Lateinicus/tree/master"}>
|
||||
<ListItemIcon>
|
||||
<InfoIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Über" />
|
||||
</ListItem>
|
||||
</List>
|
||||
</SwipeableDrawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
38
frontend/src/components/SummaryTable.tsx
Normal file
38
frontend/src/components/SummaryTable.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
|
||||
import Table from "@material-ui/core/Table";
|
||||
import TableHead from "@material-ui/core/TableHead";
|
||||
import TableBody from "@material-ui/core/TableBody";
|
||||
import TableRow from "@material-ui/core/TableRow";
|
||||
import TableCell from "@material-ui/core/TableCell";
|
||||
|
||||
import { IReviewMetadata } from "../models/review";
|
||||
|
||||
interface IProps {
|
||||
reviewMeta: () => IReviewMetadata;
|
||||
}
|
||||
|
||||
export default class SummaryTable extends React.Component<IProps> {
|
||||
render() {
|
||||
const meta = this.props.reviewMeta();
|
||||
|
||||
return <Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Antworten</TableCell>
|
||||
<TableCell>Anzahl</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Korrekt</TableCell>
|
||||
<TableCell numeric>{meta.correct}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Falsch</TableCell>
|
||||
<TableCell numeric>{meta.wrong}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>;
|
||||
}
|
||||
}
|
||||
318
frontend/src/components/app.tsx
Normal file
318
frontend/src/components/app.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { BrowserRouter, Route, Redirect } from "react-router-dom";
|
||||
|
||||
import AuthRoute from "../security/AuthRoute";
|
||||
import { setSessionToken, removeSessionToken } from "../security/Token";
|
||||
|
||||
import Dashboard from "../pages/dashboard";
|
||||
import LoginPage from "../containers/LoginPage";
|
||||
import LevelListPage from "../containers/LevelList";
|
||||
import LevelPage from "../containers/LevelPage";
|
||||
import ReviewPage from "../containers/Review";
|
||||
import SummaryPage from "../containers/SummaryPage";
|
||||
import WelcomePage from "../pages/intro";
|
||||
|
||||
import Drawer from "../containers/Drawer";
|
||||
|
||||
import { BACKEND_URL } from "../config";
|
||||
|
||||
import { ILevel } from "../models/level";
|
||||
import { ILearner } from "../models/learner";
|
||||
import { IVocab, VocabType } from "../models/vocab";
|
||||
import { IReviewMetadata, ReviewType } from "../models/review";
|
||||
import { IUser } from "../models/user";
|
||||
|
||||
interface IProps {
|
||||
authenticated: boolean;
|
||||
|
||||
setAuthenticated: (status: boolean) => void;
|
||||
setUser: (user: IUser) => void;
|
||||
};
|
||||
|
||||
// TODO: Replace the sessionStorage with localStorage?
|
||||
// TODO: Cache API-Calls
|
||||
// TODO: When mounting without a login, check if the sessionToken is still valid
|
||||
export default class Application extends React.Component<IProps> {
|
||||
getLevels(): Promise<ILevel[]> {
|
||||
console.log("STUB: Application::getLevels");
|
||||
|
||||
return new Promise((res, rej) => {
|
||||
// TODO: Actually fetch them from somewhere
|
||||
setTimeout(() => {
|
||||
const levels = [{
|
||||
name: "Der Bauer auf dem Feld",
|
||||
desc: "So fängt alles an: Du bist ein einfacher Bauer und musst dich die Karriereleiter mit deinen freshen Latein-Skills hinaufarbeiten",
|
||||
level: 1,
|
||||
done: true,
|
||||
}, {
|
||||
name: "???",
|
||||
desc: "Warum schreibe ich überhaupt was?dsd dddddddddddddddddddddd",
|
||||
level: 2,
|
||||
done: false,
|
||||
}];
|
||||
|
||||
res(levels);
|
||||
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
getLastReview = (): IReviewMetadata => {
|
||||
console.log("STUB: Application::getLastReview");
|
||||
|
||||
// TODO: Actually fetch this
|
||||
// TODO: Stub
|
||||
return {} as IReviewMetadata;
|
||||
}
|
||||
|
||||
setLastReview = (meta: IReviewMetadata) => {
|
||||
console.log("STUB: Application::setLastReview");
|
||||
|
||||
// TODO: Send this to the server
|
||||
this.setState({
|
||||
lastReview: meta,
|
||||
});
|
||||
}
|
||||
|
||||
getReviewQueue = (): Promise<IVocab[]> => {
|
||||
console.log("STUB: Application::getReviewQueue");
|
||||
|
||||
// TODO: Implement
|
||||
return new Promise((res, rej) => {
|
||||
setTimeout(() => {
|
||||
res([
|
||||
{
|
||||
german: ["Wein"],
|
||||
hint: "Worte auf '-um' sind meistens NeutrUM",
|
||||
type: VocabType.NOMEN,
|
||||
latin: {
|
||||
grundform: "Vinum",
|
||||
genitiv: "Vini",
|
||||
genus: "Neutrum"
|
||||
},
|
||||
id: 0
|
||||
}/* , {
|
||||
* latin: "Vici",
|
||||
* german: "<Wortbedeutung>",
|
||||
* hint: "Wird \"Viki\" und nicht \"Vichi\" ausgesprochen",
|
||||
* mnemonic: "Merk dir das Wort mit Caesars berühmten Worten: \"Veni Vidi Vici\"; Er kam, sah und siegte",
|
||||
* type: VocabType.NOMEN,
|
||||
* id: 2
|
||||
}, {
|
||||
* latin: "fuga",
|
||||
* german: "Flucht",
|
||||
* hint: "Worte auf \"-a\" sind FeminA",
|
||||
* type: VocabType.NOMEN,
|
||||
* id: 3
|
||||
} */
|
||||
]);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
getLearners(): ILearner[] {
|
||||
console.log("STUB: Application::getLearners");
|
||||
|
||||
// TODO: Implement
|
||||
return [{
|
||||
username: "Polynomdivision",
|
||||
level: 5,
|
||||
score: 400,
|
||||
}, {
|
||||
username: "Polynomdivision2",
|
||||
level: 3,
|
||||
score: 500,
|
||||
}, {
|
||||
username: "Der eine Typ",
|
||||
level: 7,
|
||||
score: 100,
|
||||
}];
|
||||
}
|
||||
|
||||
getTopTenLearners(): ILearner[] {
|
||||
console.log("STUB: Application::getTopTenLearners");
|
||||
|
||||
// TODO: Implement
|
||||
return [{
|
||||
username: "Polynomdivision",
|
||||
level: 5,
|
||||
score: 400,
|
||||
}, {
|
||||
username: "Polynomdivision2",
|
||||
level: 3,
|
||||
score: 500,
|
||||
}, {
|
||||
username: "Der eine Typ",
|
||||
level: 7,
|
||||
score: 100,
|
||||
}];
|
||||
}
|
||||
|
||||
getNextLevel(): ILevel {
|
||||
console.log("STUB: Application::getNextLevel");
|
||||
|
||||
// TODO: Actually fetch data
|
||||
return {
|
||||
name: "???",
|
||||
desc: "Warum schreibe ich überhaupt was?dsd dddddddddddddddddddddd",
|
||||
level: 2,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
getLevelVocab(id: number): Promise<IVocab[]> {
|
||||
console.log("STUB: Application::getLevelVocab");
|
||||
|
||||
// TODO: Actually implement this
|
||||
return new Promise((res, rej) => {
|
||||
setTimeout(() => {
|
||||
res([{
|
||||
german: ["Wein"],
|
||||
hint: "Worte auf '-um' sind meistens NeutrUM",
|
||||
type: VocabType.NOMEN,
|
||||
latin: {
|
||||
grundform: "Vinum",
|
||||
genitiv: "Vini",
|
||||
genus: "Neutrum"
|
||||
},
|
||||
id: 0
|
||||
}/* , {
|
||||
* latin: "Vici",
|
||||
* german: "<Wortbedeutung>",
|
||||
* hint: "Wird \"Viki\" und nicht \"Vichi\" ausgesprochen",
|
||||
* mnemonic: "Merk dir das Wort mit Caesars berühmten Worten: \"Veni Vidi Vici\"; Er kam, sah und siegte",
|
||||
* type: VocabType.NOMEN,
|
||||
* id: 2
|
||||
}, {
|
||||
* latin: "fuga",
|
||||
* german: "Flucht",
|
||||
* hint: "Worte auf \"-a\" sind FeminA",
|
||||
* type: VocabType.NOMEN,
|
||||
* id: 3
|
||||
} */]);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
login = (username: string, password: string): Promise<IUser | {}> => {
|
||||
return new Promise((res, rej) => {
|
||||
fetch(`${BACKEND_URL}/login`, {
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
// NOTE: We will force HTTPS, so this should not be a
|
||||
// problem
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
}).then(data => data.json())
|
||||
.then(resp => {
|
||||
if (resp.error === "0") {
|
||||
// Successful login
|
||||
this.props.setUser(resp.data);
|
||||
setSessionToken(window, resp.data.sessionToken);
|
||||
|
||||
res(resp.data);
|
||||
} else {
|
||||
rej({});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
logout = () => {
|
||||
// TODO: Tell the server that we're logging ourselves out
|
||||
removeSessionToken(window);
|
||||
this.props.setAuthenticated(false);
|
||||
}
|
||||
|
||||
// Checks whether the user is logged in
|
||||
isAuthenticated = () => {
|
||||
// TODO: Security?
|
||||
// TODO: Implement
|
||||
return this.props.authenticated;
|
||||
}
|
||||
|
||||
render() {
|
||||
// TODO: Show a spinner before mounting the routes, so that we can
|
||||
// check if were authenticated before doing any requests
|
||||
return <BrowserRouter
|
||||
basename="/app/">
|
||||
<div className="flex" >
|
||||
<Drawer logout={this.logout} />
|
||||
<div className="content">
|
||||
<Route exact path="/" component={() => <Redirect to="/login" />} />
|
||||
<Route exact path="/login" component={() => {
|
||||
return <LoginPage login={this.login} />
|
||||
}} />
|
||||
<AuthRoute
|
||||
isAuth={this.isAuthenticated}
|
||||
path="/dashboard"
|
||||
component={() => {
|
||||
return <Dashboard
|
||||
nextLevel={this.getNextLevel}
|
||||
getLastReview={this.getLastReview}
|
||||
getTopTen={this.getTopTenLearners} />
|
||||
}} />
|
||||
<AuthRoute
|
||||
isAuth={this.isAuthenticated}
|
||||
path="/welcome"
|
||||
component={() => {
|
||||
return <WelcomePage />
|
||||
}} />
|
||||
<AuthRoute
|
||||
isAuth={this.isAuthenticated}
|
||||
path="/levelList"
|
||||
component={() => <LevelListPage
|
||||
getLevels={this.getLevels} />} />
|
||||
{/*We cannot use AuthRoute here, because match is undefined otherwise*/}
|
||||
<Route
|
||||
path="/level/:id"
|
||||
component={({ match }) => {
|
||||
if (this.isAuthenticated()) {
|
||||
return <LevelPage
|
||||
id={match.params.id}
|
||||
levelVocab={this.getLevelVocab}
|
||||
drawerButtonState={this.drawerButtonState}
|
||||
setLastReview={this.setLastReview} />;
|
||||
} else {
|
||||
return <Redirect to="/login" />;
|
||||
}
|
||||
}} />
|
||||
<Route
|
||||
path="/review/level/:id"
|
||||
component={({ match }) => {
|
||||
if (this.isAuthenticated()) {
|
||||
return <ReviewPage
|
||||
reviewType={ReviewType.LEVEL}
|
||||
levelId={match.params.id}
|
||||
vocabByLevel={this.getLevelVocab}
|
||||
setLastReview={this.setLastReview} />;
|
||||
} else {
|
||||
return <Redirect to="/login" />;
|
||||
}
|
||||
}} />
|
||||
<AuthRoute
|
||||
isAuth={this.isAuthenticated}
|
||||
path="/review/queue"
|
||||
component={() => {
|
||||
return <ReviewPage
|
||||
reviewType={ReviewType.QUEUE}
|
||||
vocabByQueue={this.getReviewQueue}
|
||||
drawerButtonState={this.drawerButtonState}
|
||||
setLastReview={this.setLastReview} />;
|
||||
}} />
|
||||
<AuthRoute
|
||||
isAuth={this.isAuthenticated}
|
||||
path="/review/summary"
|
||||
component={() => {
|
||||
return <SummaryPage />
|
||||
}} />
|
||||
</div>
|
||||
</div >
|
||||
</BrowserRouter >;
|
||||
}
|
||||
};
|
||||
7
frontend/src/components/loading.tsx
Normal file
7
frontend/src/components/loading.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
export default class Loader extends React.Component<{}> {
|
||||
render() {
|
||||
return <div className="loader" />;
|
||||
}
|
||||
}
|
||||
73
frontend/src/components/scoreboard.tsx
Normal file
73
frontend/src/components/scoreboard.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import * as React from "react";
|
||||
|
||||
import Table from "@material-ui/core/Table";
|
||||
import TableHead from "@material-ui/core/TableHead";
|
||||
import TableBody from "@material-ui/core/TableBody";
|
||||
import TableRow from "@material-ui/core/TableRow";
|
||||
import TableCell from "@material-ui/core/TableCell";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
|
||||
import { ILearner } from "../models/learner";
|
||||
|
||||
interface IProps {
|
||||
topTen: ILearner[];
|
||||
}
|
||||
|
||||
export default class Scoreboard extends React.Component<IProps> {
|
||||
private unique = 0;
|
||||
private nr = 1;
|
||||
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
|
||||
this.genId = this.genId.bind(this);
|
||||
this.tableRow = this.tableRow.bind(this);
|
||||
}
|
||||
|
||||
genId() {
|
||||
return "SCOREBOARD" + this.unique++;
|
||||
}
|
||||
|
||||
tableRow(learner: ILearner) {
|
||||
return <TableRow key={this.genId()}>
|
||||
<TableCell>
|
||||
<Typography variant="title" component="b">
|
||||
{this.nr++}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography component="b">{learner.username}</Typography>
|
||||
</TableCell>
|
||||
{/*<TableCell numeric>{learner.level}</TableCell>*/}
|
||||
{/* To make this fit on both mobile and desktop, we don't use
|
||||
numeric, as it would otherwise look weir otherwise look weird */}
|
||||
<TableCell>{learner.score}</TableCell>
|
||||
</TableRow>
|
||||
}
|
||||
|
||||
render() {
|
||||
const sortedLearners = this.props.topTen.sort((a, b) => {
|
||||
if (a.score > b.score) {
|
||||
return -1;
|
||||
} else if (a.score < b.score) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return <Table padding="none">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>#</TableCell>
|
||||
<TableCell>User</TableCell>
|
||||
{/*<TableCell>Level</TableCell>*/}
|
||||
<TableCell>Punktzahl</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sortedLearners.map(this.tableRow)}
|
||||
</TableBody>
|
||||
</Table>;
|
||||
}
|
||||
}
|
||||
4
frontend/src/config.ts
Normal file
4
frontend/src/config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Maximum distance from the answer to be still considered correct
|
||||
export const LEVENSHTEIN_MAX_DISTANCE = 2;
|
||||
|
||||
export const BACKEND_URL = "http://127.0.0.1:8080";
|
||||
24
frontend/src/containers/Application.ts
Normal file
24
frontend/src/containers/Application.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import { IUser } from "../models/user";
|
||||
|
||||
import Application from "../components/app";
|
||||
|
||||
import { setAuthenticated, setUser } from "../actions";
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
authenticated: state.authenticated,
|
||||
};
|
||||
};
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
setAuthenticated: (status: boolean) => dispatch(setAuthenticated(status)),
|
||||
setUser: (user: IUser) => dispatch(setUser(user)),
|
||||
};
|
||||
};
|
||||
|
||||
const ApplicationContainer = connect(mapStateToProps,
|
||||
mapDispatchToProps)(Application);
|
||||
|
||||
export default ApplicationContainer;
|
||||
23
frontend/src/containers/Drawer.ts
Normal file
23
frontend/src/containers/Drawer.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import { setDrawer } from "../actions";
|
||||
|
||||
import Drawer from "../components/Drawer";
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
user: state.user,
|
||||
open: state.drawer,
|
||||
authenticated: state.authenticated,
|
||||
showButton: state.drawerButton,
|
||||
};
|
||||
};
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
setDrawer: (show: boolean) => dispatch(setDrawer(show)),
|
||||
};
|
||||
};
|
||||
|
||||
const DrawerContainer = connect(mapStateToProps,
|
||||
mapDispatchToProps)(Drawer);
|
||||
export default DrawerContainer;
|
||||
24
frontend/src/containers/LevelList.ts
Normal file
24
frontend/src/containers/LevelList.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import { setLevelListLoading, setLevels } from "../actions";
|
||||
|
||||
import { ILevel } from "../models/level";
|
||||
|
||||
import LevelListPage from "../pages/levelList";
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
levels: state.levels,
|
||||
loading: state.levelList.loading,
|
||||
};
|
||||
};
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
setLoading: (state: boolean) => dispatch(setLevelListLoading(state)),
|
||||
setLevels: (levels: ILevel[]) => dispatch(setLevels(levels)),
|
||||
};
|
||||
};
|
||||
|
||||
const LevelListContainer = connect(mapStateToProps,
|
||||
mapDispatchToProps)(LevelListPage);
|
||||
export default LevelListContainer;
|
||||
35
frontend/src/containers/LevelPage.ts
Normal file
35
frontend/src/containers/LevelPage.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import {
|
||||
setDrawerButton, setLevelLookedAt,
|
||||
setLevelCurrentVocab, setLevelVocab, setLevelLoading
|
||||
} from "../actions";
|
||||
|
||||
import { IVocab } from "../models/vocab";
|
||||
|
||||
import LevelPage from "../pages/level";
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const { currentVocab, lookedAt, vocab, loading } = state.level;
|
||||
|
||||
return {
|
||||
currentVocab,
|
||||
lookedAt,
|
||||
vocab,
|
||||
loading,
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
drawerButtonState: (state: boolean) => dispatch(setDrawerButton(state)),
|
||||
setLookedAt: (ids: number[]) => dispatch(setLevelLookedAt(ids)),
|
||||
setCurrentVocab: (vocab: IVocab) => dispatch(setLevelCurrentVocab(vocab)),
|
||||
setVocab: (vocab: IVocab[]) => dispatch(setLevelVocab(vocab)),
|
||||
setLoading: (state: boolean) => dispatch(setLevelLoading(state)),
|
||||
};
|
||||
};
|
||||
|
||||
const LevelPageContainer = connect(mapStateToProps,
|
||||
mapDispatchToProps)(LevelPage);
|
||||
export default LevelPageContainer;
|
||||
23
frontend/src/containers/LoginPage.ts
Normal file
23
frontend/src/containers/LoginPage.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import { setLoginSnackbar, setLoginLoading } from "../actions";
|
||||
|
||||
import LoginPage from "../pages/login";
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
loading: state.login.loading,
|
||||
snackOpen: state.login.snackOpen,
|
||||
snackMsg: state.login.snackMsg,
|
||||
authenticated: state.authenticated,
|
||||
};
|
||||
};
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
setLoading: (state: boolean) => dispatch(setLoginLoading(state)),
|
||||
setSnackbar: (state: boolean, msg: string) => dispatch(setLoginSnackbar(state, msg)),
|
||||
};
|
||||
};
|
||||
|
||||
const LoginPageContainer = connect(mapStateToProps, mapDispatchToProps)(LoginPage);
|
||||
export default LoginPageContainer;
|
||||
36
frontend/src/containers/Review.ts
Normal file
36
frontend/src/containers/Review.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import {
|
||||
setDrawerButton, setReviewPopover, setReviewSummary, setLastReview,
|
||||
setReview, setReviewLoading
|
||||
} from "../actions";
|
||||
|
||||
import { IReviewMetadata } from "../models/review";
|
||||
import { IVocab } from "../models/vocab";
|
||||
import ReviewPage from "../pages/review";
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
metadata: state.review.metadata,
|
||||
vocab: state.review.vocab,
|
||||
current: state.review.current,
|
||||
popoverOpen: state.review.popoverOpen,
|
||||
popoverText: state.review.popoverText,
|
||||
popoverColor: state.review.popoverColor,
|
||||
popoverTextColor: state.review.popoverTextColor,
|
||||
loading: state.review.loading,
|
||||
};
|
||||
};
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
drawerButtonState: (state: boolean) => dispatch(setDrawerButton(state)),
|
||||
setPopover: (state: boolean, text: string, color: string, textColor: string) => dispatch(setReviewPopover(state, text, color, textColor)),
|
||||
setSummary: (state: boolean) => dispatch(setReviewSummary(state)),
|
||||
setReview: (current: IVocab, meta: IReviewMetadata) => dispatch(setReview(current, meta)),
|
||||
setLoading: (state: boolean) => dispatch(setReviewLoading(state)),
|
||||
};
|
||||
};
|
||||
|
||||
const ReviewContainer = connect(mapStateToProps,
|
||||
mapDispatchToProps)(ReviewPage);
|
||||
export default ReviewContainer;
|
||||
21
frontend/src/containers/SummaryPage.ts
Normal file
21
frontend/src/containers/SummaryPage.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import { setDrawerButton } from "../actions";
|
||||
|
||||
import SummaryPage from "../pages/summary";
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
reviewMeta: state.lastReview,
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
setDrawerButton: (state: boolean) => dispatch(setDrawerButton(state)),
|
||||
};
|
||||
};
|
||||
|
||||
const SummaryPageContainer = connect(mapStateToProps,
|
||||
mapDispatchToProps)(SummaryPage);
|
||||
export default SummaryPageContainer;
|
||||
63
frontend/src/index.css
Normal file
63
frontend/src/index.css
Normal file
@@ -0,0 +1,63 @@
|
||||
body {
|
||||
margin: 0px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.flex {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.toolbarLoginBtn {
|
||||
margin-left: -12px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.flex-parent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
}
|
||||
|
||||
.paper {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lesson-card-lg {
|
||||
height: 300px;
|
||||
}
|
||||
.lesson-card-xs {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.lesson-card-btn {
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.vert-spacer {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.intro-card-lg {
|
||||
width: 50%;
|
||||
}
|
||||
.intro-card-xs {
|
||||
width: 100%;
|
||||
}
|
||||
.intro-subheading {
|
||||
border-right: solid;
|
||||
border-left: solid;
|
||||
|
||||
border-color: red;
|
||||
}
|
||||
20
frontend/src/index.html
Normal file
20
frontend/src/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Lateinicus</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="./index.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500">
|
||||
|
||||
<script src="https://unpkg.com/react@16/umd/react.development.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js" crossorigin></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="./index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
17
frontend/src/index.tsx
Normal file
17
frontend/src/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
|
||||
import { createStore } from "redux";
|
||||
import { Provider } from "react-redux";
|
||||
|
||||
import { LateinicusApp } from "./reducers";
|
||||
|
||||
import Application from "./containers/Application";
|
||||
|
||||
const store = createStore(LateinicusApp);
|
||||
|
||||
ReactDOM.render((
|
||||
<Provider store={store}>
|
||||
<Application />
|
||||
</Provider>
|
||||
), document.getElementById("app"));
|
||||
5
frontend/src/models/learner.ts
Normal file
5
frontend/src/models/learner.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface ILearner {
|
||||
username: string;
|
||||
level: number;
|
||||
score: number;
|
||||
}
|
||||
7
frontend/src/models/level.ts
Normal file
7
frontend/src/models/level.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface ILevel {
|
||||
name: string;
|
||||
desc: string;
|
||||
level: number;
|
||||
|
||||
done: boolean;
|
||||
}
|
||||
11
frontend/src/models/review.ts
Normal file
11
frontend/src/models/review.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface IReviewMetadata {
|
||||
// Number of correct answers
|
||||
correct: number;
|
||||
// Number of wrong answers
|
||||
wrong: number;
|
||||
};
|
||||
|
||||
export enum ReviewType {
|
||||
LEVEL,
|
||||
QUEUE
|
||||
};
|
||||
8
frontend/src/models/user.ts
Normal file
8
frontend/src/models/user.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface IUser {
|
||||
username: string;
|
||||
uid: string;
|
||||
showWelcome: boolean;
|
||||
score: number;
|
||||
|
||||
sessionToken: string;
|
||||
};
|
||||
130
frontend/src/models/vocab.ts
Normal file
130
frontend/src/models/vocab.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
export enum ReviewMode {
|
||||
GER_TO_LAT,
|
||||
LAT_TO_GER,
|
||||
};
|
||||
|
||||
export enum VocabType {
|
||||
NOMEN,
|
||||
VERB,
|
||||
ADJEKTIV,
|
||||
ADVERB
|
||||
};
|
||||
|
||||
export interface INomenData {
|
||||
grundform: string;
|
||||
genitiv: string;
|
||||
genus: string;
|
||||
};
|
||||
|
||||
export interface IVerbData {
|
||||
grundform: string;
|
||||
// 1. Person
|
||||
praesens: string;
|
||||
perfekt: string;
|
||||
ppp: string;
|
||||
};
|
||||
|
||||
export interface IAdjektivData {
|
||||
grundform: string;
|
||||
nominativ_a: string;
|
||||
nominativ_b: string;
|
||||
};
|
||||
|
||||
export interface IVocab {
|
||||
// If a word has multiple meanings
|
||||
german: string[];
|
||||
hint?: string;
|
||||
mnemonic?: string;
|
||||
|
||||
type: VocabType;
|
||||
latin: INomenData | IVerbData | IAdjektivData;
|
||||
|
||||
// This number is unique across all vocabulary items
|
||||
id: number;
|
||||
};
|
||||
|
||||
// What kind of question should be answered
|
||||
export enum ReviewQType {
|
||||
GERMAN,
|
||||
|
||||
NOMEN_GENITIV,
|
||||
NOMEN_GENUS,
|
||||
|
||||
ADJ_NOM_A,
|
||||
ADJ_NOM_B,
|
||||
|
||||
VERB_PRAESENS,
|
||||
VERB_PERFEKT,
|
||||
VERB_PPP
|
||||
};
|
||||
|
||||
export interface IReviewCard {
|
||||
question: string;
|
||||
// If a question can have multiple answers
|
||||
answers: string[];
|
||||
|
||||
qtype: ReviewQType;
|
||||
|
||||
// Identical to its corresponding IVocab item
|
||||
id: number;
|
||||
};
|
||||
|
||||
export function reviewQTypeToStr(type: ReviewQType): string {
|
||||
switch (type) {
|
||||
case ReviewQType.GERMAN:
|
||||
return "Übersetzung";
|
||||
case ReviewQType.NOMEN_GENITIV:
|
||||
return "Genitiv";
|
||||
case ReviewQType.NOMEN_GENUS:
|
||||
return "Genus";
|
||||
case ReviewQType.ADJ_NOM_A:
|
||||
// TODO
|
||||
return "Nominativ A";
|
||||
case ReviewQType.ADJ_NOM_B:
|
||||
// TODO
|
||||
return "Nominativ B";
|
||||
case ReviewQType.VERB_PRAESENS:
|
||||
return "1. Person Präsens";
|
||||
case ReviewQType.VERB_PERFEKT:
|
||||
return "1. Person Perfekt";
|
||||
case ReviewQType.VERB_PPP:
|
||||
// TODO
|
||||
return "PPP";
|
||||
}
|
||||
}
|
||||
|
||||
// Turn a vocabulaty item into a series of questions about the item
|
||||
export function vocabToReviewCard(vocab: IVocab): IReviewCard[] {
|
||||
switch (vocab.type) {
|
||||
case VocabType.NOMEN:
|
||||
const latin = vocab.latin as INomenData;
|
||||
return [{
|
||||
// Latin -> German
|
||||
question: latin.grundform,
|
||||
answers: vocab.german,
|
||||
qtype: ReviewQType.GERMAN,
|
||||
id: vocab.id,
|
||||
}, {
|
||||
// Latin -> Genitiv
|
||||
question: latin.grundform,
|
||||
answers: [latin.genitiv],
|
||||
qtype: ReviewQType.NOMEN_GENITIV,
|
||||
id: vocab.id,
|
||||
}, {
|
||||
// Latin -> Genus
|
||||
question: latin.grundform,
|
||||
answers: [latin.genus],
|
||||
qtype: ReviewQType.NOMEN_GENUS,
|
||||
id: vocab.id,
|
||||
}];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function typeToPoints(type: VocabType) {
|
||||
switch (type) {
|
||||
// Nomen: 2P + 1 (Wenn richtig)
|
||||
//
|
||||
}
|
||||
};
|
||||
74
frontend/src/pages/dashboard.tsx
Normal file
74
frontend/src/pages/dashboard.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import Grid from "@material-ui/core/Grid";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
|
||||
import Scoreboard from "../components/scoreboard";
|
||||
import SummaryTable from "../components/SummaryTable";
|
||||
|
||||
import { ILevel } from "../models/level";
|
||||
import { ILearner } from "../models/learner";
|
||||
import { IReviewMetadata } from "../models/review";
|
||||
|
||||
interface IProps {
|
||||
nextLevel: () => ILevel;
|
||||
getLastReview: () => IReviewMetadata;
|
||||
getTopTen: () => ILearner[];
|
||||
}
|
||||
|
||||
export default class Dashboard extends React.Component<IProps> {
|
||||
render() {
|
||||
const small = window.matchMedia("(max-width: 700px)").matches;
|
||||
const direction = small ? "column" : "row";
|
||||
|
||||
const level = this.props.nextLevel();
|
||||
|
||||
return <div>
|
||||
<Grid container direction={direction} spacing={16}>
|
||||
<Grid item lg={4}>
|
||||
<Paper className="paper">
|
||||
<Typography variant="title">{`Level ${level.level}`}</Typography>
|
||||
<Typography variant="title" component="p">{level.name}</Typography>
|
||||
<br />
|
||||
<Typography component="p">
|
||||
{level.desc}
|
||||
</Typography>
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/level/${level.level}`}
|
||||
className="lesson-card-btn">
|
||||
Zum Level
|
||||
</Button>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item lg={4}>
|
||||
<Paper className="paper">
|
||||
<Typography variant="title" component="p">
|
||||
Rangliste: Top 10
|
||||
</Typography>
|
||||
|
||||
<Scoreboard topTen={this.props.getTopTen()} />
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item lg={4}>
|
||||
<Paper className="paper">
|
||||
<Typography variant="title">
|
||||
Letzte Wiederholung
|
||||
</Typography>
|
||||
<SummaryTable reviewMeta={this.props.getLastReview} />
|
||||
|
||||
<Button
|
||||
component={Link}
|
||||
to="/review/queue">
|
||||
Vokabeln üben
|
||||
</Button>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>;
|
||||
}
|
||||
};
|
||||
83
frontend/src/pages/intro.tsx
Normal file
83
frontend/src/pages/intro.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import Grid from "@material-ui/core/Grid";
|
||||
import Card from "@material-ui/core/Card";
|
||||
import CardContent from "@material-ui/core/CardContent";
|
||||
import CardActions from "@material-ui/core/CardActions";
|
||||
import Button from "@material-ui/core/Button";
|
||||
|
||||
export default class IntroPage extends React.Component<{}> {
|
||||
render() {
|
||||
const small = window.matchMedia("(max-width: 700px)").matches;
|
||||
const cName = small ? "intro-card-xs" : "intro-card-lg";
|
||||
return <div>
|
||||
<Grid container justify="center">
|
||||
<Card className={cName}>
|
||||
<CardContent>
|
||||
<Typography
|
||||
component="p"
|
||||
variant="title">
|
||||
Wilkommen bei Lateinicus!
|
||||
</Typography>
|
||||
|
||||
<div className="vert-spacer" />
|
||||
<Typography
|
||||
className="intro-subheading"
|
||||
component="p"
|
||||
variant="subheading">
|
||||
Was ist Lateinicus?
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
component="p">
|
||||
Lateinicus ist eine experimentelle Lernanwendung für Lateinvokabeln. Mit Hilfe dieser
|
||||
Anwendung wollen wir die Frage beantworten, ob "Gamification" tatsächlich effektiver
|
||||
ist als klassisches Lernen mit Vokabelheft.
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
component="p">
|
||||
Um die Effektivität zu bewerten wird vor und nach Verwendung von Lateinicus
|
||||
ein Vokabeltest geschrieben, welche von uns zum Zwecke der Datenerhebung benutzt
|
||||
werden.
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
component="p">
|
||||
Zudem erfassen wir Daten darüber, wann und wie lange gelernt wird, damit wir diese
|
||||
Werte auch in unser Fazit mit einfließen lassen können.
|
||||
</Typography>
|
||||
|
||||
<div className="vert-spacer" />
|
||||
<Typography
|
||||
className="intro-subheading"
|
||||
component="p"
|
||||
variant="subheading">
|
||||
Was ist Gamification?
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body1">
|
||||
Gamification ist ein Konzept, bei welchem eine Tätigkeit, wie hier das Lernen von
|
||||
Lateinvokabeln, in ein Spiel umgewandelt wird.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
{/*TODO: Tell the server to not show this page again*/}
|
||||
<Button
|
||||
fullWidth={true}
|
||||
component={Link}
|
||||
to="/dashboard">
|
||||
Lass uns loslegen
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
</div>;
|
||||
}
|
||||
};
|
||||
187
frontend/src/pages/level.tsx
Normal file
187
frontend/src/pages/level.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import * as React from "react";
|
||||
|
||||
import List from "@material-ui/core/List";
|
||||
import ListItem from "@material-ui/core/ListItem";
|
||||
import ListItemText from "@material-ui/core/ListItemText";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
import Grid from "@material-ui/core/Grid";
|
||||
import Card from "@material-ui/core/Card";
|
||||
import CardContent from "@material-ui/core/CardContent";
|
||||
import CircularProgress from "@material-ui/core/CircularProgress";
|
||||
|
||||
import { withRouter } from "react-router-dom";
|
||||
|
||||
import { IVocab } from "../models/vocab";
|
||||
|
||||
interface IProps {
|
||||
id: string;
|
||||
levelVocab: (id: string) => Promise<IVocab[]>;
|
||||
|
||||
history: any;
|
||||
|
||||
loading: boolean;
|
||||
setLoading: (state: boolean) => void;
|
||||
vocab: IVocab[];
|
||||
setVocab: (vocab: IVocab[]) => void;
|
||||
setLookedAt: (ids: number[]) => void;
|
||||
setCurrentVocab: (vocab: IVocab) => void;
|
||||
drawerButtonState: (state: boolean) => void;
|
||||
currentVocab: IVocab;
|
||||
lookedAt: number[];
|
||||
};
|
||||
|
||||
const LevelPageWithRouter = withRouter(
|
||||
class LevelPage extends React.Component<IProps> {
|
||||
private uid = 0;
|
||||
// To prevent React from redrawing the vocabulary list and prematurely
|
||||
// cancelling the animation
|
||||
private uids: { [key: string]: string } = {};
|
||||
|
||||
componentDidMount() {
|
||||
// Hide the drawer
|
||||
this.props.drawerButtonState(false);
|
||||
|
||||
// Fetch the vocabulary
|
||||
this.props.setLoading(true);
|
||||
|
||||
// TODO: Error handling
|
||||
this.props.levelVocab(this.props.id).then(vocab => {
|
||||
this.props.setVocab(vocab);
|
||||
this.props.setCurrentVocab(vocab[0]);
|
||||
this.props.setLoading(false);
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
genUID = (vocab: IVocab): string => {
|
||||
const { grundform } = vocab.latin;
|
||||
if (grundform in this.uids) {
|
||||
return this.uids[grundform];
|
||||
} else {
|
||||
this.uids[grundform] = "LEVELPAGE" + this.uid++;
|
||||
return this.uids[grundform];
|
||||
}
|
||||
}
|
||||
|
||||
renderVocabListItem = (vocab: IVocab): any => {
|
||||
// Check if the vocab was already looked at
|
||||
const lookedAt = this.props.lookedAt.find((el) => el === vocab.id) || vocab.id === 0;
|
||||
|
||||
return <ListItem button key={this.genUID(vocab)} onClick={() => {
|
||||
// Prevent the user from using too much memory by always clicking on the elements
|
||||
// Show the clicked at vocab word
|
||||
|
||||
this.props.setCurrentVocab(vocab);
|
||||
this.props.setLookedAt(lookedAt ? (
|
||||
this.props.lookedAt
|
||||
) : this.props.lookedAt.concat(vocab.id));
|
||||
}}>
|
||||
<ListItemText>
|
||||
{`${vocab.latin.grundform} ${lookedAt ? "✔" : ""}`}
|
||||
</ListItemText>
|
||||
</ListItem>;
|
||||
}
|
||||
|
||||
toReview = () => {
|
||||
const { vocab, lookedAt, id } = this.props;
|
||||
// Only go to the review if all vocabulary item have been looked at
|
||||
if (vocab.length === lookedAt.length) {
|
||||
this.props.setLoading(true);
|
||||
this.props.history.push(`/review/level/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
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 { currentVocab } = this.props;
|
||||
return <div>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={3}>
|
||||
<List>
|
||||
{this.props.vocab
|
||||
.map(this.renderVocabListItem)}
|
||||
<ListItem button onClick={this.toReview}>
|
||||
<ListItemText>
|
||||
Zur Übung
|
||||
</ListItemText>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Grid>
|
||||
<Grid item lg={7} xs={9}>
|
||||
<Grid container direction="column">
|
||||
<Grid item style={{ margin: 12 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography gutterBottom variant="headline" component="h2">
|
||||
{currentVocab.latin.grundform}
|
||||
</Typography>
|
||||
<Typography gutterBottom variant="headline" component="h3">
|
||||
{currentVocab.german.join(", ")}
|
||||
</Typography>
|
||||
{
|
||||
currentVocab.hint ? (
|
||||
<div style={{
|
||||
border: "dashed",
|
||||
borderColor: "red",
|
||||
padding: 12,
|
||||
}}>
|
||||
<Typography variant="subheading" component="p">
|
||||
<b>Tipp:</b>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{currentVocab.hint}
|
||||
</Typography>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
{
|
||||
currentVocab.mnemonic ? (
|
||||
<div style={{
|
||||
border: "dashed",
|
||||
borderColor: "#f1c40f",
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
}}>
|
||||
<Typography variant="subheading" component="p">
|
||||
<b>Eselsbrücke:</b>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{currentVocab.mnemonic}
|
||||
</Typography>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
</CardContent>
|
||||
{/*TODO: Maybe "next" and "prev" buttons?*/}
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
);
|
||||
export default LevelPageWithRouter;
|
||||
91
frontend/src/pages/levelList.tsx
Normal file
91
frontend/src/pages/levelList.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import * as React from "react";
|
||||
|
||||
import Grid from "@material-ui/core/Grid";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import Card from '@material-ui/core/Card';
|
||||
import CardActions from '@material-ui/core/CardActions';
|
||||
import CardContent from '@material-ui/core/CardContent';
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
import CircularProgress from "@material-ui/core/CircularProgress";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { ILevel } from "../models/level";
|
||||
|
||||
interface IProps {
|
||||
getLevels: () => Promise<ILevel[]>;
|
||||
|
||||
setLevels: (levels: ILevel[]) => void;
|
||||
setLoading: (state: boolean) => void;
|
||||
loading: boolean;
|
||||
levels: ILevel[];
|
||||
}
|
||||
|
||||
export default class Dashboard extends React.Component<IProps> {
|
||||
componentDidMount() {
|
||||
this.props.setLoading(true);
|
||||
|
||||
// Fetch the levels
|
||||
this.props.getLevels().then(res => {
|
||||
this.props.setLevels(res);
|
||||
this.props.setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
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 small = window.matchMedia("(max-width: 700px)").matches;
|
||||
const cName = small ? "lesson-card-xs" : "lesson-card-lg";
|
||||
|
||||
let key = 0;
|
||||
const levelToCard = (level: ILevel) => {
|
||||
return <Grid item key={key++}>
|
||||
<Card style={{
|
||||
width: small ? window.width - 32 : "300px"
|
||||
}}>
|
||||
<CardContent className={cName}>
|
||||
<Typography variant="title">{`Level ${level.level}`}</Typography>
|
||||
<Typography variant="title" component="p">{level.name}</Typography>
|
||||
<br />
|
||||
<Typography component="p">
|
||||
{level.desc}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/level/${level.level}`}
|
||||
className="lesson-card-btn">
|
||||
Zum Level
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>;
|
||||
};
|
||||
|
||||
return <Grid container spacing={16} direction="row">
|
||||
{this.props.levels.map(levelToCard)}
|
||||
</Grid>
|
||||
}
|
||||
};
|
||||
109
frontend/src/pages/login.tsx
Normal file
109
frontend/src/pages/login.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from "react";
|
||||
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
import Grid from "@material-ui/core/Grid";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import LinearProgress from "@material-ui/core/LinearProgress";
|
||||
import Snackbar from "@material-ui/core/Snackbar";
|
||||
|
||||
import { withRouter } from "react-router-dom";
|
||||
|
||||
import { IUser } from "../models/user";
|
||||
|
||||
interface IProps {
|
||||
login: (username: string, password: string) => Promise<IUser | {}>;
|
||||
authenticated: boolean;
|
||||
history: any;
|
||||
|
||||
setLoading: (state: boolean) => void;
|
||||
setSnackbar: (state: boolean, msg: string) => void;
|
||||
loading: boolean;
|
||||
snackOpen: boolean;
|
||||
snackMsg: string;
|
||||
}
|
||||
|
||||
const LoginPageWithRouter = withRouter(
|
||||
class LoginPage extends React.Component<IProps> {
|
||||
private usernameRef: any = undefined;
|
||||
private passwordRef: any = undefined;
|
||||
|
||||
performLogin = () => {
|
||||
this.props.setLoading(true);
|
||||
|
||||
const username = this.usernameRef.value || "";
|
||||
const password = this.passwordRef.value || "";
|
||||
this.props.login(username, password).then((res: IUser) => {
|
||||
if (res.showWelcome) {
|
||||
// If the user logs in for the first time, a welcome
|
||||
// screen should be shown
|
||||
this.props.history.push("/welcome");
|
||||
} else {
|
||||
this.props.history.push("/dashboard");
|
||||
}
|
||||
}, (err) => {
|
||||
this.props.setLoading(false);
|
||||
this.props.setSnackbar(true, "Failed to log in");
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// If we're already authenticated, we can skip the login page
|
||||
if (this.props.authenticated) {
|
||||
this.props.history.push("/dashboard");
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div>
|
||||
<Grid
|
||||
container
|
||||
spacing={0}
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justify="center"
|
||||
style={{ minHeight: '100vh' }}>
|
||||
<Grid item xs={12}>
|
||||
<Paper className="paper">
|
||||
<Typography variant="title">Login</Typography>
|
||||
<Grid container direction="column" spacing={8}>
|
||||
<Grid item>
|
||||
<TextField
|
||||
label="Username"
|
||||
inputRef={node => this.usernameRef = node} />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<TextField
|
||||
label="Passwort"
|
||||
type="password"
|
||||
inputRef={node => this.passwordRef = node} />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="login-btn"
|
||||
onClick={() => this.performLogin()}>
|
||||
Login
|
||||
</Button>
|
||||
{
|
||||
this.props.loading ? (
|
||||
<LinearProgress />
|
||||
) : undefined
|
||||
}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Snackbar
|
||||
open={this.props.snackOpen}
|
||||
onClose={() => this.props.setSnackbar(false, "")}
|
||||
message={this.props.snackMsg}
|
||||
autoHideDuration={6000} />
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
);
|
||||
export default LoginPageWithRouter;
|
||||
243
frontend/src/pages/review.tsx
Normal file
243
frontend/src/pages/review.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
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 { 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 } from "../config";
|
||||
|
||||
import { Queue } from "../utils/queue";
|
||||
|
||||
interface IProps {
|
||||
levelId?: number;
|
||||
vocabByLevel?: (level: number) => Promise<IVocab[]>;
|
||||
vocabByQueue?: () => Promise<IVocab[]>;
|
||||
reviewType: ReviewType;
|
||||
|
||||
history: any;
|
||||
|
||||
loading: boolean;
|
||||
vocab: IVocab[];
|
||||
current: IReviewCard;
|
||||
popoverOpen: boolean;
|
||||
popoverText: string;
|
||||
popoverColor: string;
|
||||
popoverTextColor: string;
|
||||
|
||||
setSummary: (state: boolean) => void;
|
||||
setPopover: (state: boolean, text: string, color: string, textColor: string) => void;
|
||||
drawerButtonState: (state: boolean) => void;
|
||||
setLastReview: (meta: IReviewMetadata) => 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;
|
||||
private metadata: IReviewMetadata = { correct: 0, wrong: 0 };
|
||||
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
|
||||
// Hide the drawer button
|
||||
this.props.drawerButtonState(false);
|
||||
|
||||
const vocToQueue = () => {
|
||||
this.vocab.forEach((vocab) => {
|
||||
vocabToReviewCard(vocab).forEach(this.reviewQueue.enqueue);
|
||||
});
|
||||
this.props.setReview(this.reviewQueue.dequeue(), {
|
||||
correct: 0,
|
||||
wrong: 0,
|
||||
});
|
||||
this.props.setLoading(false);
|
||||
};
|
||||
|
||||
// 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[]) => {
|
||||
this.vocab = res;
|
||||
vocToQueue();
|
||||
});
|
||||
}
|
||||
|
||||
increaseMeta = (correct: number, wrong: number): IReviewMetadata => {
|
||||
const { metadata } = this;
|
||||
|
||||
return {
|
||||
wrong: metadata.wrong + wrong,
|
||||
correct: metadata.correct + correct,
|
||||
};
|
||||
}
|
||||
|
||||
vocabFromId = (id: number): IVocab | {} => {
|
||||
return this.vocab.find((el) => el.id === this.props.current.id);
|
||||
}
|
||||
|
||||
checkInput = () => {
|
||||
// Check if the given answer is somewhere in the german words
|
||||
const input = this.inputRef.value || "";
|
||||
|
||||
// Map all possible answers to lowercase ( => ignore casing)
|
||||
const answers = this.props.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);
|
||||
|
||||
console.log("Review Queue size:", this.reviewQueue.size());
|
||||
|
||||
// Check if the user's answer was correct
|
||||
if (minDist === 0) {
|
||||
// TODO: Show it's correct?
|
||||
// Show the next vocab word
|
||||
if (this.reviewQueue.size() === 0) {
|
||||
// Go to the summary screen
|
||||
this.props.setLastReview(this.metadata);
|
||||
this.props.setLoading(true);
|
||||
this.props.history.push("/review/summary");
|
||||
} else {
|
||||
// Increase the vocab
|
||||
this.props.setReview(this.reviewQueue.dequeue(), this.increaseMeta(1, 0));
|
||||
this.inputRef.value = "";
|
||||
}
|
||||
} else if (minDist <= LEVENSHTEIN_MAX_DISTANCE) {
|
||||
this.props.setPopover(true, "Das war fast richtig", "yellow", "black");
|
||||
} else {
|
||||
// Find the IVocab item
|
||||
const vocab = this.vocabFromId(this.props.current.id);
|
||||
if (vocab) {
|
||||
// Re-Add the vocabulary item to the review queue
|
||||
// TODO: Only re-add when it when it's not re-queued
|
||||
// vocabToReviewCard(vocab).forEach(this.reviewQueue.enqueue);
|
||||
} else {
|
||||
console.log("[ReviewPage::checkInput] Could not find IVocab item for wrong IReviewCard");
|
||||
}
|
||||
|
||||
this.props.setPopover(true, "Das war nicht richtig", "red", "white");
|
||||
}
|
||||
|
||||
// TODO(?): Show a snackbar for showing the updated score
|
||||
}
|
||||
|
||||
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:
|
||||
const progress = 50;
|
||||
|
||||
return <div>
|
||||
<Grid container justify="center">
|
||||
<Grid item style={{ width: "100%" }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Grid container direction="column">
|
||||
<Typography variant="display2">
|
||||
{questionTitle}
|
||||
</Typography>
|
||||
<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>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
);
|
||||
export default ReviewPageWithRouter;
|
||||
56
frontend/src/pages/summary.tsx
Normal file
56
frontend/src/pages/summary.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
import Grid from "@material-ui/core/Grid";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import SummaryTable from "../components/SummaryTable";
|
||||
|
||||
import { withRouter } from "react-router-dom";
|
||||
|
||||
import { IReviewMetadata } from "../models/review";
|
||||
|
||||
interface IProps {
|
||||
history: any;
|
||||
|
||||
reviewMeta: IReviewMetadata;
|
||||
setDrawerButton: (state: boolean) => void;
|
||||
}
|
||||
|
||||
// TODO: This stays at the default value
|
||||
const SummaryPageWithRouter = withRouter(
|
||||
class SummaryPage extends React.Component<IProps> {
|
||||
toDashboard = () => {
|
||||
// Show the drawer button
|
||||
this.props.setDrawerButton(true);
|
||||
|
||||
// Go to the dashboard
|
||||
this.props.history.push("/dashboard");
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div>
|
||||
<Grid
|
||||
container
|
||||
spacing={0}
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justify="center"
|
||||
style={{ minHeight: '100vh' }}>
|
||||
<Grid item xs={12}>
|
||||
<Paper className="paper">
|
||||
<Typography variant="title">Zusammenfassung</Typography>
|
||||
<Grid container direction="column">
|
||||
<SummaryTable reviewMeta={() => this.props.reviewMeta} />
|
||||
<Button onClick={this.toDashboard}>
|
||||
Zum Dashboard
|
||||
</Button>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
);
|
||||
export default SummaryPageWithRouter;
|
||||
208
frontend/src/reducers/index.ts
Normal file
208
frontend/src/reducers/index.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import * as Actions from "../actions";
|
||||
|
||||
import { ILearner } from "../models/learner";
|
||||
import { ILevel } from "../models/level";
|
||||
import { IUser } from "../models/user";
|
||||
import { IVocab } from "../models/vocab";
|
||||
import { IReviewCard, IReviewMetadata } from "../models/review";
|
||||
|
||||
interface IState {
|
||||
drawer: boolean;
|
||||
drawerButton: boolean;
|
||||
authenticated: boolean;
|
||||
|
||||
// TODO: Rework this
|
||||
user: IUser | {},
|
||||
|
||||
// All available levels
|
||||
levels: ILevel[];
|
||||
|
||||
login: {
|
||||
loading: boolean;
|
||||
snackMsg: string;
|
||||
snackOpen: boolean;
|
||||
};
|
||||
|
||||
level: {
|
||||
currentVocab: IVocab;
|
||||
lookedAt: number[];
|
||||
vocab: IVocab[];
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
levelList: {
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
review: {
|
||||
current: IReviewCard;
|
||||
|
||||
loading: boolean;
|
||||
vocab: IVocab[];
|
||||
metadata: IReviewMetadata;
|
||||
popoverOpen: boolean;
|
||||
popoverText: string;
|
||||
popoverColor: string;
|
||||
popoverTextColor: string;
|
||||
};
|
||||
|
||||
topTen: ILearner[];
|
||||
|
||||
lastReview: any;
|
||||
};
|
||||
|
||||
const initialState: IState = {
|
||||
// Show the drawer?
|
||||
drawer: false,
|
||||
// Should we show the button to open the drawer?
|
||||
drawerButton: true,
|
||||
// Is the user authenticated?
|
||||
// TODO: Set this to false
|
||||
authenticated: true,
|
||||
|
||||
user: {},
|
||||
|
||||
login: {
|
||||
loading: false,
|
||||
snackMsg: "",
|
||||
snackOpen: false,
|
||||
},
|
||||
|
||||
levels: [],
|
||||
|
||||
level: {
|
||||
currentVocab: {} as IVocab,
|
||||
lookedAt: [0],
|
||||
vocab: [],
|
||||
loading: true,
|
||||
},
|
||||
|
||||
levelList: {
|
||||
loading: true,
|
||||
},
|
||||
|
||||
review: {
|
||||
current: {} as IReviewCard,
|
||||
|
||||
loading: true,
|
||||
vocab: [],
|
||||
metadata: {} as IReviewMetadata,
|
||||
popoverOpen: false,
|
||||
popoverText: "",
|
||||
popoverColor: "",
|
||||
popoverTextColor: "",
|
||||
},
|
||||
|
||||
lastReview: {
|
||||
correct: 0,
|
||||
wrong: 0,
|
||||
},
|
||||
|
||||
// The top ten
|
||||
topTen: [],
|
||||
};
|
||||
|
||||
export function LateinicusApp(state: IState = initialState, action: any) {
|
||||
switch (action.type) {
|
||||
case Actions.SET_DRAWER:
|
||||
return Object.assign({}, state, {
|
||||
drawer: action.show,
|
||||
});
|
||||
case Actions.SET_DRAWER_BUTTON:
|
||||
return Object.assign({}, state, {
|
||||
drawerButton: action.show,
|
||||
});
|
||||
case Actions.LOGIN_SET_SNACKBAR:
|
||||
return Object.assign({}, state, {
|
||||
login: {
|
||||
loading: state.login.loading,
|
||||
snackMsg: action.msg,
|
||||
snackOpen: action.show,
|
||||
},
|
||||
});
|
||||
case Actions.LOGIN_SET_LOADING:
|
||||
return Object.assign({}, state, {
|
||||
login: {
|
||||
loading: action.show,
|
||||
snackMsg: state.login.snackMsg,
|
||||
snackOpen: state.login.snackOpen,
|
||||
},
|
||||
});
|
||||
case Actions.SET_AUTHENTICATED:
|
||||
return Object.assign({}, state, {
|
||||
authenticated: action.status,
|
||||
});
|
||||
case Actions.SET_USER:
|
||||
return Object.assign({}, state, {
|
||||
user: action.user,
|
||||
});
|
||||
case Actions.LEVEL_SET_LOOKEDAT:
|
||||
return Object.assign({}, state, {
|
||||
level: Object.assign({}, state.level, {
|
||||
lookedAt: action.lookedAt,
|
||||
}),
|
||||
});
|
||||
case Actions.LEVEL_SET_CUR_VOCAB:
|
||||
return Object.assign({}, state, {
|
||||
level: Object.assign({}, state.level, {
|
||||
currentVocab: action.vocab,
|
||||
}),
|
||||
});
|
||||
case Actions.LEVEL_SET_VOCAB:
|
||||
return Object.assign({}, state, {
|
||||
level: Object.assign({}, state.level, {
|
||||
vocab: action.vocab,
|
||||
}),
|
||||
});
|
||||
case Actions.LEVEL_SET_LOADING:
|
||||
return Object.assign({}, state, {
|
||||
level: Object.assign({}, state.level, {
|
||||
loading: action.state,
|
||||
}),
|
||||
});
|
||||
case Actions.SET_LEVELS:
|
||||
return Object.assign({}, state, {
|
||||
levels: action.levels,
|
||||
});
|
||||
case Actions.REVIEW_SET_POPOVER:
|
||||
return Object.assign({}, state, {
|
||||
review: Object.assign({}, state.review, {
|
||||
popoverText: action.text,
|
||||
popoverOpen: action.state,
|
||||
popoverColor: action.color,
|
||||
popoverTextColor: action.textColor,
|
||||
}),
|
||||
});
|
||||
case Actions.SET_REVIEW:
|
||||
return Object.assign({}, state, {
|
||||
review: Object.assign({}, state.review, {
|
||||
current: action.current,
|
||||
metadata: action.meta,
|
||||
}),
|
||||
});
|
||||
case Actions.SET_LAST_REVIEW:
|
||||
return Object.assign({}, state, {
|
||||
lastReview: action.metadata,
|
||||
});
|
||||
case Actions.REVIEW_SET_LOADING:
|
||||
return Object.assign({}, state, {
|
||||
review: Object.assign({}, state.review, {
|
||||
loading: action.state,
|
||||
}),
|
||||
});
|
||||
case Actions.LEVELLIST_SET_LOADING:
|
||||
return Object.assign({}, state, {
|
||||
levelList: Object.assign({}, state.levelList, {
|
||||
loading: action.state,
|
||||
}),
|
||||
});
|
||||
default:
|
||||
// Ignore the initialization call to the reducer. By that we can
|
||||
// catch all actions that are not implemented
|
||||
if (action.type && !action.type.startsWith("@@redux/INIT")) {
|
||||
console.log("Reducer not implemented:", action.type);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
};
|
||||
18
frontend/src/security/AuthRoute.tsx
Normal file
18
frontend/src/security/AuthRoute.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Route, Redirect } from "react-router-dom";
|
||||
|
||||
interface IAuthRouteProps {
|
||||
path: string;
|
||||
component: any;
|
||||
isAuth: () => boolean;
|
||||
}
|
||||
|
||||
export default class AuthRoute extends React.Component<IAuthRouteProps, {}> {
|
||||
render() {
|
||||
const auth = this.props.isAuth();
|
||||
return <Route path={this.props.path} component={
|
||||
() => auth ? <this.props.component /> : <Redirect to="/login" />
|
||||
} />;
|
||||
}
|
||||
};
|
||||
7
frontend/src/security/Token.ts
Normal file
7
frontend/src/security/Token.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function setSessionToken(window: Window, token: string) {
|
||||
window.sessionStorage.setItem("sessionToken", token);
|
||||
};
|
||||
|
||||
export function removeSessionToken(window: Window) {
|
||||
window.sessionStorage.removeItem("sessionToken");
|
||||
}
|
||||
16
frontend/src/utils/__test__/queue.test.ts
Normal file
16
frontend/src/utils/__test__/queue.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Queue } from "../queue";
|
||||
|
||||
test("Enqueue a, b and c and dequeue them", () => {
|
||||
const q: Queue = new Queue<string>();
|
||||
q.enqueue("a");
|
||||
q.enqueue("b");
|
||||
q.enqueue("c");
|
||||
|
||||
expect(q.size()).toBe(3);
|
||||
|
||||
expect(q.dequeue()).toEqual("a");
|
||||
expect(q.dequeue()).toEqual("b");
|
||||
expect(q.dequeue()).toEqual("c");
|
||||
|
||||
expect(q.size()).toBe(0);
|
||||
});
|
||||
9
frontend/src/utils/date.ts
Normal file
9
frontend/src/utils/date.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const DAY_IN_MILLI = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function daysToMilli(n: number): number {
|
||||
return DAY_IN_MILLI * n;
|
||||
};
|
||||
|
||||
export function dayInNDays(n: number): number {
|
||||
return Date.now() * daysToMilli(n);
|
||||
}
|
||||
15
frontend/src/utils/queue.ts
Normal file
15
frontend/src/utils/queue.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export class Queue<T> {
|
||||
private elements: T[] = [];
|
||||
|
||||
enqueue = (element: T) => {
|
||||
this.elements.push(element);
|
||||
}
|
||||
|
||||
dequeue = (): T => {
|
||||
return this.elements.shift();
|
||||
}
|
||||
|
||||
size = (): number => {
|
||||
return this.elements.length;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user