refactor: MONOREPO

This commit is contained in:
Alexander Polynomdivision
2018-09-20 17:38:12 +02:00
parent 4c9e328ad0
commit 909149fdc7
50 changed files with 222 additions and 3 deletions

View 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,
};
}

View File

@@ -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);
});

View 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);
}

View 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;
}

View 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>
);
}
};

View 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>;
}
}

View 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 >;
}
};

View File

@@ -0,0 +1,7 @@
import * as React from "react";
export default class Loader extends React.Component<{}> {
render() {
return <div className="loader" />;
}
}

View 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
View 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";

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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
View 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"));

View File

@@ -0,0 +1,5 @@
export interface ILearner {
username: string;
level: number;
score: number;
}

View File

@@ -0,0 +1,7 @@
export interface ILevel {
name: string;
desc: string;
level: number;
done: boolean;
}

View 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
};

View File

@@ -0,0 +1,8 @@
export interface IUser {
username: string;
uid: string;
showWelcome: boolean;
score: number;
sessionToken: string;
};

View 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)
//
}
};

View 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>;
}
};

View 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>;
}
};

View 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;

View 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>
}
};

View 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;

View 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;

View 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;

View 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;
}
};

View 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" />
} />;
}
};

View 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");
}

View 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);
});

View 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);
}

View 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;
}
};