839 lines
27 KiB
TypeScript
839 lines
27 KiB
TypeScript
|
/**
|
||
|
* Copyright (c) 2021 lingdocs.com
|
||
|
*
|
||
|
* This source code is licensed under the GPL3 license found in the
|
||
|
* LICENSE file in the root directory of this source tree.
|
||
|
*
|
||
|
*/
|
||
|
|
||
|
// TODO: Put the DB sync on the localDb object, and then have it cancel()'ed and removed as part of the deinitialization
|
||
|
// sync on initialization and cancel sync on de-initialization
|
||
|
|
||
|
import { Component } from "react";
|
||
|
import {
|
||
|
capitalizeFirstLetter,
|
||
|
defaultTextOptions,
|
||
|
revertSpelling,
|
||
|
standardizePashto,
|
||
|
Types as T,
|
||
|
} from "@lingdocs/ps-react";
|
||
|
import { withRouter, Route, RouteComponentProps, Link } from "react-router-dom";
|
||
|
import Helmet from "react-helmet";
|
||
|
import BottomNavItem from "./components/BottomNavItem";
|
||
|
import SearchBar from "./components/SearchBar";
|
||
|
import DictionaryStatusDisplay from "./components/DictionaryStatusDisplay";
|
||
|
import About from "./screens/About";
|
||
|
import Options from "./screens/Options";
|
||
|
import Results from "./screens/Results";
|
||
|
import Account from "./screens/Account";
|
||
|
import ReviewTasks from "./screens/ReviewTasks";
|
||
|
import EntryEditor from "./screens/EntryEditor";
|
||
|
import IsolatedEntry from "./screens/IsolatedEntry";
|
||
|
import PrivacyPolicy from "./screens/PrivacyPolicy";
|
||
|
import Wordlist from "./screens/Wordlist";
|
||
|
import {
|
||
|
saveOptions,
|
||
|
readOptions,
|
||
|
saveUser,
|
||
|
readUser,
|
||
|
} from "./lib/local-storage";
|
||
|
import { allEntries, dictionary, pageSize } from "./lib/dictionary";
|
||
|
import { optionsReducer, textOptionsReducer } from "./lib/options-reducer";
|
||
|
import hitBottom from "./lib/hitBottom";
|
||
|
import getWordId from "./lib/get-word-id";
|
||
|
import { CronJob } from "cron";
|
||
|
import Mousetrap from "mousetrap";
|
||
|
import { sendSubmissions } from "./lib/submissions";
|
||
|
import { getUser } from "./lib/backend-calls";
|
||
|
import { getWordlist } from "./lib/wordlist-database";
|
||
|
import {
|
||
|
startLocalDbs,
|
||
|
stopLocalDbs,
|
||
|
getAllDocsLocalDb,
|
||
|
} from "./lib/pouch-dbs";
|
||
|
import { forReview } from "./lib/spaced-repetition";
|
||
|
import { textBadge } from "./lib/badges";
|
||
|
import * as AT from "./types/account-types";
|
||
|
import ReactGA from "react-ga4";
|
||
|
import classNames from "classnames";
|
||
|
import { getTextOptions } from "./lib/get-text-options";
|
||
|
import { getTextFromShareTarget } from "./lib/share-target";
|
||
|
import { objIsEqual, userObjIsEqual } from "./lib/misc-helpers";
|
||
|
import {
|
||
|
State,
|
||
|
TextOptionsRecord,
|
||
|
TextOptionsAction,
|
||
|
OptionsAction,
|
||
|
} from "./types/dictionary-types";
|
||
|
import PhraseBuilder from "./screens/PhraseBuilder";
|
||
|
import { searchAllInflections } from "./lib/search-all-inflections";
|
||
|
import { addToWordlist } from "./lib/wordlist-database";
|
||
|
import ScriptToPhonetics from "./screens/ScriptToPhonetics";
|
||
|
import { pNums, convertNumShortcutToNum } from "./lib/misc-helpers";
|
||
|
|
||
|
const newWordsPeriod: "week" | "month" = "month";
|
||
|
|
||
|
// to allow Moustrap key combos even when input fields are in focus
|
||
|
Mousetrap.prototype.stopCallback = function () {
|
||
|
return false;
|
||
|
};
|
||
|
|
||
|
const prod = document.location.hostname === "dictionary.lingdocs.com";
|
||
|
|
||
|
if (prod) {
|
||
|
// TODO: migrate to https://www.npmjs.com/package/react-ga4
|
||
|
ReactGA.initialize("G-TPQY0GKDCW");
|
||
|
ReactGA.set({ anonymizeIp: true });
|
||
|
}
|
||
|
|
||
|
const possibleLandingPages = [
|
||
|
"/",
|
||
|
"/about",
|
||
|
"/settings",
|
||
|
"/word",
|
||
|
"/account",
|
||
|
"/new-entries",
|
||
|
"/share-target",
|
||
|
"/phrase-builder",
|
||
|
"/privacy",
|
||
|
"/script-to-phonetics",
|
||
|
];
|
||
|
const editorOnlyPages = ["/edit", "/review-tasks"];
|
||
|
|
||
|
class App extends Component<RouteComponentProps, State> {
|
||
|
constructor(props: RouteComponentProps) {
|
||
|
super(props);
|
||
|
const savedOptions = readOptions();
|
||
|
this.state = {
|
||
|
dictionaryStatus: "loading",
|
||
|
dictionaryInfo: undefined,
|
||
|
showModal: false,
|
||
|
// TODO: Choose between the saved options and the options in the saved user
|
||
|
options: savedOptions
|
||
|
? savedOptions
|
||
|
: {
|
||
|
language: "Pashto",
|
||
|
searchType: "fuzzy",
|
||
|
searchBarStickyFocus: false,
|
||
|
theme: window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||
|
? "dark"
|
||
|
: "light",
|
||
|
textOptionsRecord: {
|
||
|
lastModified: Date.now() as AT.TimeStamp,
|
||
|
textOptions: defaultTextOptions,
|
||
|
},
|
||
|
wordlistMode: "browse",
|
||
|
wordlistReviewLanguage: "Pashto",
|
||
|
wordlistReviewBadge: true,
|
||
|
searchBarPosition: "top",
|
||
|
},
|
||
|
searchValue: "",
|
||
|
page: 1,
|
||
|
isolatedEntry: undefined,
|
||
|
results: [],
|
||
|
wordlist: [],
|
||
|
reviewTasks: [],
|
||
|
user: readUser(),
|
||
|
inflectionSearchResults: undefined,
|
||
|
};
|
||
|
this.handleOptionsUpdate = this.handleOptionsUpdate.bind(this);
|
||
|
this.handleTextOptionsUpdate = this.handleTextOptionsUpdate.bind(this);
|
||
|
this.handleSearchValueChange = this.handleSearchValueChange.bind(this);
|
||
|
this.handleIsolateEntry = this.handleIsolateEntry.bind(this);
|
||
|
this.handleScroll = this.handleScroll.bind(this);
|
||
|
this.handleGoBack = this.handleGoBack.bind(this);
|
||
|
this.handleLoadUser = this.handleLoadUser.bind(this);
|
||
|
this.handleRefreshWordlist = this.handleRefreshWordlist.bind(this);
|
||
|
this.handleRefreshReviewTasks = this.handleRefreshReviewTasks.bind(this);
|
||
|
this.handleDictionaryUpdate = this.handleDictionaryUpdate.bind(this);
|
||
|
this.handleInflectionSearch = this.handleInflectionSearch.bind(this);
|
||
|
}
|
||
|
|
||
|
public componentDidMount() {
|
||
|
window.addEventListener("scroll", this.handleScroll);
|
||
|
if (!possibleLandingPages.includes(this.props.location.pathname)) {
|
||
|
this.props.history.replace("/");
|
||
|
}
|
||
|
if (prod && !(this.state.user?.level === "editor")) {
|
||
|
ReactGA.send({
|
||
|
hitType: "pageview",
|
||
|
page: window.location.pathname + window.location.search,
|
||
|
});
|
||
|
}
|
||
|
dictionary
|
||
|
.initialize()
|
||
|
.then((r) => {
|
||
|
this.cronJob.start();
|
||
|
this.setState({
|
||
|
dictionaryStatus: "ready",
|
||
|
dictionaryInfo: r.dictionaryInfo,
|
||
|
});
|
||
|
this.handleLoadUser();
|
||
|
// incase it took forever and timed out - might need to reinitialize the wordlist here ??
|
||
|
if (this.state.user) {
|
||
|
startLocalDbs(this.state.user, {
|
||
|
wordlist: this.handleRefreshWordlist,
|
||
|
reviewTasks: this.handleRefreshReviewTasks,
|
||
|
});
|
||
|
}
|
||
|
if (this.props.location.pathname === "/word") {
|
||
|
const wordId = getWordId(this.props.location.search);
|
||
|
if (wordId) {
|
||
|
const word = dictionary.findOneByTs(wordId);
|
||
|
if (word) {
|
||
|
this.setState({ searchValue: word.p });
|
||
|
}
|
||
|
this.handleIsolateEntry(wordId);
|
||
|
} else {
|
||
|
// TODO: Make a word not found screen
|
||
|
console.error("somehow had a word path without a word id param");
|
||
|
this.props.history.replace("/");
|
||
|
}
|
||
|
}
|
||
|
if (this.props.location.pathname === "/share-target") {
|
||
|
const searchString = getTextFromShareTarget(window.location);
|
||
|
this.props.history.replace("/");
|
||
|
if (this.state.options.language === "English") {
|
||
|
this.handleOptionsUpdate({ type: "toggleLanguage" });
|
||
|
}
|
||
|
if (this.state.options.searchType === "alphabetical") {
|
||
|
this.handleOptionsUpdate({ type: "toggleSearchType" });
|
||
|
}
|
||
|
this.handleSearchValueChange(searchString);
|
||
|
}
|
||
|
if (this.props.location.pathname === "/new-entries") {
|
||
|
this.setState({
|
||
|
results: dictionary.getNewWords(newWordsPeriod),
|
||
|
page: 1,
|
||
|
});
|
||
|
}
|
||
|
if (r.response === "loaded from saved") {
|
||
|
this.handleDictionaryUpdate();
|
||
|
}
|
||
|
})
|
||
|
.catch((error) => {
|
||
|
console.error(error);
|
||
|
this.setState({ dictionaryStatus: "error loading" });
|
||
|
});
|
||
|
document.documentElement.setAttribute(
|
||
|
"data-theme",
|
||
|
this.state.options.theme
|
||
|
);
|
||
|
/* istanbul ignore if */
|
||
|
if (window.matchMedia) {
|
||
|
const prefersDarkQuery = window.matchMedia(
|
||
|
"(prefers-color-scheme: dark)"
|
||
|
);
|
||
|
prefersDarkQuery.addListener((e) => {
|
||
|
if (e.matches) {
|
||
|
this.handleOptionsUpdate({ type: "changeTheme", payload: "dark" });
|
||
|
}
|
||
|
});
|
||
|
const prefersLightQuery = window.matchMedia(
|
||
|
"(prefers-color-scheme: light)"
|
||
|
);
|
||
|
prefersLightQuery.addListener((e) => {
|
||
|
if (e.matches) {
|
||
|
this.handleOptionsUpdate({ type: "changeTheme", payload: "light" });
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
// shortcuts to isolote word in search results
|
||
|
Mousetrap.bind(
|
||
|
["1", "2", "3", "4", "5", "6", "7", "8", "9", ...pNums],
|
||
|
(e) => {
|
||
|
e.preventDefault();
|
||
|
if (e.repeat) {
|
||
|
return;
|
||
|
}
|
||
|
if (this.props.location.pathname !== "/search") {
|
||
|
return;
|
||
|
}
|
||
|
const toIsolate =
|
||
|
this.state.results[convertNumShortcutToNum(e.key) - 1];
|
||
|
if (!toIsolate) {
|
||
|
return;
|
||
|
}
|
||
|
this.handleIsolateEntry(toIsolate.ts);
|
||
|
}
|
||
|
);
|
||
|
Mousetrap.bind(
|
||
|
["ctrl+down", "ctrl+up", "command+down", "command+up"],
|
||
|
(e) => {
|
||
|
e.preventDefault();
|
||
|
if (e.repeat) {
|
||
|
return;
|
||
|
}
|
||
|
this.handleOptionsUpdate({ type: "toggleLanguage" });
|
||
|
}
|
||
|
);
|
||
|
Mousetrap.bind(["ctrl+b", "command+b"], (e) => {
|
||
|
e.preventDefault();
|
||
|
if (e.repeat) {
|
||
|
return;
|
||
|
}
|
||
|
this.handleSearchValueChange("");
|
||
|
});
|
||
|
Mousetrap.bind(["ctrl+i", "command+i"], (e) => {
|
||
|
e.preventDefault();
|
||
|
if (e.repeat) {
|
||
|
return;
|
||
|
}
|
||
|
if (!this.state.searchValue) {
|
||
|
return;
|
||
|
}
|
||
|
this.handleInflectionSearch();
|
||
|
});
|
||
|
Mousetrap.bind(["ctrl+s", "command+s"], (e) => {
|
||
|
if (this.state.user?.level === "basic") {
|
||
|
return;
|
||
|
}
|
||
|
e.preventDefault();
|
||
|
if (!this.state.isolatedEntry) {
|
||
|
return;
|
||
|
}
|
||
|
const toAdd = {
|
||
|
entry: this.state.isolatedEntry,
|
||
|
notes: "",
|
||
|
};
|
||
|
addToWordlist(toAdd);
|
||
|
});
|
||
|
Mousetrap.bind(["ctrl+\\", "command+\\"], (e) => {
|
||
|
e.preventDefault();
|
||
|
if (e.repeat) {
|
||
|
return;
|
||
|
}
|
||
|
if (this.state.user?.level === "basic") {
|
||
|
return;
|
||
|
}
|
||
|
if (this.props.location.pathname !== "/wordlist") {
|
||
|
this.props.history.push("/wordlist");
|
||
|
} else {
|
||
|
this.handleGoBack();
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
public componentWillUnmount() {
|
||
|
window.removeEventListener("scroll", this.handleScroll);
|
||
|
this.cronJob.stop();
|
||
|
stopLocalDbs();
|
||
|
Mousetrap.unbind(["ctrl+down", "ctrl+up", "command+down", "command+up"]);
|
||
|
Mousetrap.unbind(["ctrl+b", "command+b"]);
|
||
|
Mousetrap.unbind(["ctrl+\\", "command+\\"]);
|
||
|
Mousetrap.unbind(["ctrl+s", "command+s"]);
|
||
|
Mousetrap.unbind(["ctrl+i", "command+i"]);
|
||
|
Mousetrap.unbind(["1", "2", "3", "4", "5", "6", "7", "8", "9"]);
|
||
|
}
|
||
|
|
||
|
public componentDidUpdate(prevProps: RouteComponentProps) {
|
||
|
if (this.props.location.pathname !== prevProps.location.pathname) {
|
||
|
if (prod && !(this.state.user?.level === "editor")) {
|
||
|
ReactGA.send({
|
||
|
hitType: "pageview",
|
||
|
page: window.location.pathname + window.location.search,
|
||
|
});
|
||
|
}
|
||
|
if (this.props.location.pathname === "/") {
|
||
|
this.handleSearchValueChange("");
|
||
|
}
|
||
|
if (this.props.location.pathname === "/new-entries") {
|
||
|
this.setState({
|
||
|
results: dictionary.getNewWords(newWordsPeriod),
|
||
|
page: 1,
|
||
|
});
|
||
|
}
|
||
|
if (
|
||
|
editorOnlyPages.includes(this.props.location.pathname) &&
|
||
|
!(this.state.user?.level === "editor")
|
||
|
) {
|
||
|
this.props.history.replace("/");
|
||
|
}
|
||
|
}
|
||
|
if (
|
||
|
getWordId(this.props.location.search) !==
|
||
|
getWordId(prevProps.location.search)
|
||
|
) {
|
||
|
if (prod && this.state.user?.level !== "editor") {
|
||
|
ReactGA.send({
|
||
|
type: "pageview",
|
||
|
page: window.location.pathname + window.location.search,
|
||
|
});
|
||
|
}
|
||
|
const wordId = getWordId(this.props.location.search);
|
||
|
/* istanbul ignore else */
|
||
|
if (wordId) {
|
||
|
this.handleIsolateEntry(wordId, true);
|
||
|
} else {
|
||
|
this.setState({ isolatedEntry: undefined });
|
||
|
}
|
||
|
}
|
||
|
// if (!["/wordlist", "/settings", "/review-tasks"].includes(this.props.location.pathname)) {
|
||
|
// window.scrollTo(0, 0);
|
||
|
// }
|
||
|
}
|
||
|
|
||
|
private async handleLoadUser(): Promise<void> {
|
||
|
try {
|
||
|
const prevUser = this.state.user;
|
||
|
const user = await getUser();
|
||
|
if (user === "offline") {
|
||
|
return;
|
||
|
}
|
||
|
if (user) {
|
||
|
sendSubmissions();
|
||
|
}
|
||
|
if (!user) {
|
||
|
if (this.state.user) {
|
||
|
console.log("setting state user because user is newly undefined");
|
||
|
this.setState({ user: undefined });
|
||
|
}
|
||
|
saveUser(undefined);
|
||
|
return;
|
||
|
}
|
||
|
if (!userObjIsEqual(prevUser, user)) {
|
||
|
console.log(
|
||
|
"setting state user because something is different about the user"
|
||
|
);
|
||
|
this.setState({ user });
|
||
|
saveUser(user);
|
||
|
}
|
||
|
if (user) {
|
||
|
startLocalDbs(user, {
|
||
|
wordlist: this.handleRefreshWordlist,
|
||
|
reviewTasks: this.handleRefreshReviewTasks,
|
||
|
});
|
||
|
} else {
|
||
|
stopLocalDbs();
|
||
|
}
|
||
|
} catch (err) {
|
||
|
console.error("error checking user level", err);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private handleDictionaryUpdate() {
|
||
|
// TODO: fix - what the heck happened and what's going on here
|
||
|
dictionary
|
||
|
.update(() => {
|
||
|
// this.setState({ dictionaryStatus: "updating" });
|
||
|
})
|
||
|
.then(({ dictionaryInfo }) => {
|
||
|
//if (this.state.dictionaryInfo?.release !== dictionaryInfo?.release) {
|
||
|
// to avoid unnecessary re-rendering that breaks things
|
||
|
this.setState({
|
||
|
dictionaryStatus: "ready",
|
||
|
dictionaryInfo,
|
||
|
});
|
||
|
//}
|
||
|
})
|
||
|
.catch(() => {
|
||
|
this.setState({ dictionaryStatus: "error loading" });
|
||
|
});
|
||
|
}
|
||
|
|
||
|
private handleOptionsUpdate(action: OptionsAction) {
|
||
|
if (action.type === "changeTheme") {
|
||
|
document.documentElement.setAttribute("data-theme", action.payload);
|
||
|
}
|
||
|
// TODO: use a seperate reducer for changing text options (otherwise you could just be updating the saved text options instead of the user text options that the program is going off of)
|
||
|
const options = optionsReducer(this.state.options, action);
|
||
|
saveOptions(options);
|
||
|
if (
|
||
|
action.type === "toggleLanguage" ||
|
||
|
action.type === "toggleSearchType"
|
||
|
) {
|
||
|
if (this.props.location.pathname !== "/new-entries") {
|
||
|
if (
|
||
|
action.type === "toggleSearchType" &&
|
||
|
this.state.options.searchType === "fuzzy" &&
|
||
|
this.props.location.pathname !== "/search"
|
||
|
) {
|
||
|
this.handleSearchValueChange("آ");
|
||
|
}
|
||
|
this.setState((prevState) => ({
|
||
|
options,
|
||
|
page: 1,
|
||
|
results: dictionary.search({ ...prevState, options }),
|
||
|
}));
|
||
|
window.scrollTo(0, 0);
|
||
|
} else {
|
||
|
this.setState({ options });
|
||
|
}
|
||
|
} else {
|
||
|
!objIsEqual(this.state.options, options) && this.setState({ options });
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private handleTextOptionsUpdate(action: TextOptionsAction) {
|
||
|
const textOptions = textOptionsReducer(getTextOptions(this.state), action);
|
||
|
const lastModified = Date.now() as AT.TimeStamp;
|
||
|
const textOptionsRecord: TextOptionsRecord = {
|
||
|
lastModified,
|
||
|
textOptions,
|
||
|
};
|
||
|
this.handleOptionsUpdate({
|
||
|
type: "updateTextOptionsRecord",
|
||
|
payload: textOptionsRecord,
|
||
|
});
|
||
|
}
|
||
|
|
||
|
private handleSearchValueChange(searchValue: string) {
|
||
|
if (searchValue === " ") {
|
||
|
return;
|
||
|
}
|
||
|
const lastChar = searchValue[searchValue.length - 1];
|
||
|
// don't let people type in a single digit (to allow for number shortcuts)
|
||
|
// but do allow the whole thing to be numbers (to allow for pasting and searching for ts)
|
||
|
if (lastChar >= "0" && lastChar <= "9" && !/^\d+$/.test(searchValue)) {
|
||
|
return;
|
||
|
}
|
||
|
if (this.state.dictionaryStatus !== "ready") {
|
||
|
return;
|
||
|
}
|
||
|
if (searchValue === "") {
|
||
|
this.setState({
|
||
|
searchValue: "",
|
||
|
results: [],
|
||
|
page: 1,
|
||
|
inflectionSearchResults: undefined,
|
||
|
});
|
||
|
if (this.props.location.pathname !== "/") {
|
||
|
this.props.history.replace("/");
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
this.setState((prevState) => ({
|
||
|
searchValue,
|
||
|
results: dictionary.search({ ...prevState, searchValue }),
|
||
|
page: 1,
|
||
|
inflectionSearchResults: undefined,
|
||
|
}));
|
||
|
if (this.props.history.location.pathname !== "/search") {
|
||
|
this.props.history.push("/search");
|
||
|
}
|
||
|
window.scrollTo(0, 0);
|
||
|
}
|
||
|
|
||
|
private handleIsolateEntry(ts: number, onlyState?: boolean) {
|
||
|
window.scrollTo(0, 0);
|
||
|
const isolatedEntry = dictionary.findOneByTs(ts);
|
||
|
if (!isolatedEntry) {
|
||
|
console.error("couldn't find word to isolate");
|
||
|
return;
|
||
|
}
|
||
|
this.setState({ isolatedEntry });
|
||
|
if (
|
||
|
!onlyState &&
|
||
|
(this.props.location.pathname !== "/word" ||
|
||
|
getWordId(this.props.location.search) !== ts)
|
||
|
) {
|
||
|
this.props.history.push(`/word?id=${isolatedEntry.ts}`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// TODO: right now not checking user very often cause it messes with the state?
|
||
|
// causes the verb quizzer to reset?
|
||
|
private cronJob = new CronJob("1/10 * * * *", () => {
|
||
|
this.handleDictionaryUpdate();
|
||
|
this.handleLoadUser();
|
||
|
});
|
||
|
|
||
|
/* istanbul ignore next */
|
||
|
private handleScroll() {
|
||
|
if (
|
||
|
hitBottom() &&
|
||
|
this.props.location.pathname === "/search" &&
|
||
|
this.state.results.length >= pageSize * this.state.page
|
||
|
) {
|
||
|
const page = this.state.page + 1;
|
||
|
const moreResults = dictionary.search({ ...this.state, page });
|
||
|
if (moreResults.length > this.state.results.length) {
|
||
|
this.setState({
|
||
|
page,
|
||
|
results: moreResults,
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private handleInflectionSearch() {
|
||
|
function prepValueForSearch(
|
||
|
searchValue: string,
|
||
|
textOptions: T.TextOptions
|
||
|
): string {
|
||
|
const s = revertSpelling(searchValue, textOptions.spelling);
|
||
|
return standardizePashto(s.trim());
|
||
|
}
|
||
|
this.setState({ inflectionSearchResults: "searching" });
|
||
|
// need timeout to make sure the "searching" notice gets rendered before things lock up for the big search
|
||
|
setTimeout(() => {
|
||
|
const inflectionSearchResults = searchAllInflections(
|
||
|
allEntries(),
|
||
|
prepValueForSearch(
|
||
|
this.state.searchValue,
|
||
|
this.state.options.textOptionsRecord.textOptions
|
||
|
)
|
||
|
);
|
||
|
this.setState({ inflectionSearchResults });
|
||
|
}, 20);
|
||
|
}
|
||
|
|
||
|
private handleGoBack() {
|
||
|
this.props.history.goBack();
|
||
|
window.scrollTo(0, 0);
|
||
|
}
|
||
|
|
||
|
private handleRefreshWordlist() {
|
||
|
getWordlist().then((wordlist) => {
|
||
|
this.setState({ wordlist });
|
||
|
});
|
||
|
}
|
||
|
|
||
|
private handleRefreshReviewTasks() {
|
||
|
getAllDocsLocalDb("reviewTasks").then((reviewTasks) => {
|
||
|
this.setState({ reviewTasks });
|
||
|
});
|
||
|
}
|
||
|
|
||
|
render() {
|
||
|
return (
|
||
|
<div
|
||
|
style={{
|
||
|
paddingTop:
|
||
|
this.state.options.searchBarPosition === "top" ? "75px" : "7px",
|
||
|
paddingBottom: "60px",
|
||
|
}}
|
||
|
>
|
||
|
<Helmet>
|
||
|
<title>LingDocs Pashto Dictionary</title>
|
||
|
</Helmet>
|
||
|
{this.state.options.searchBarPosition === "top" && (
|
||
|
<SearchBar
|
||
|
state={this.state}
|
||
|
optionsDispatch={this.handleOptionsUpdate}
|
||
|
handleSearchValueChange={this.handleSearchValueChange}
|
||
|
/>
|
||
|
)}
|
||
|
<div className="container-fluid" data-testid="body">
|
||
|
{this.state.dictionaryStatus !== "ready" ? (
|
||
|
<DictionaryStatusDisplay status={this.state.dictionaryStatus} />
|
||
|
) : (
|
||
|
<>
|
||
|
<Route path="/" exact={true}>
|
||
|
<div className="text-center mt-4">
|
||
|
<h4 className="font-weight-light p-3 mb-4">
|
||
|
LingDocs Pashto Dictionary
|
||
|
</h4>
|
||
|
<div className="mt-4 font-weight-light">
|
||
|
<div className="mb-4 small">
|
||
|
{this.state.options.searchType === "alphabetical" ? (
|
||
|
<>
|
||
|
<span className="fa fa-book mr-2" /> Alphabetical
|
||
|
browsing mode
|
||
|
</>
|
||
|
) : (
|
||
|
<>
|
||
|
<span className="fa fa-bolt mr-2" /> Approximate
|
||
|
search mode
|
||
|
</>
|
||
|
)}
|
||
|
</div>
|
||
|
</div>
|
||
|
{this.state.user?.level === "editor" && (
|
||
|
<div className="mt-4 font-weight-light">
|
||
|
<div className="mb-3">Editor privileges active</div>
|
||
|
<Link to="/edit">
|
||
|
<button className="btn btn-secondary">New Entry</button>
|
||
|
</Link>
|
||
|
</div>
|
||
|
)}
|
||
|
<Link
|
||
|
to="/new-entries"
|
||
|
className="plain-link font-weight-light"
|
||
|
>
|
||
|
<div className="my-4">New words this {newWordsPeriod}</div>
|
||
|
</Link>
|
||
|
<div className="my-4 pt-3">
|
||
|
<Link
|
||
|
to="/phrase-builder"
|
||
|
className="plain-link h5 font-weight-light"
|
||
|
>
|
||
|
Phrase Builder
|
||
|
</Link>
|
||
|
<span className="mx-1"> • </span>
|
||
|
<a
|
||
|
href="https://grammar.lingdocs.com"
|
||
|
className="plain-link h5 font-weight-light"
|
||
|
>
|
||
|
Grammar
|
||
|
</a>
|
||
|
</div>
|
||
|
</div>
|
||
|
</Route>
|
||
|
<Route path="/about">
|
||
|
<About state={this.state} />
|
||
|
</Route>
|
||
|
<Route path="/privacy">
|
||
|
<PrivacyPolicy />
|
||
|
</Route>
|
||
|
<Route path="/phrase-builder">
|
||
|
<PhraseBuilder
|
||
|
state={this.state}
|
||
|
isolateEntry={this.handleIsolateEntry}
|
||
|
/>
|
||
|
</Route>
|
||
|
<Route path="/settings">
|
||
|
<Options
|
||
|
state={this.state}
|
||
|
options={this.state.options}
|
||
|
optionsDispatch={this.handleOptionsUpdate}
|
||
|
textOptionsDispatch={this.handleTextOptionsUpdate}
|
||
|
/>
|
||
|
</Route>
|
||
|
<Route path="/search">
|
||
|
<Results
|
||
|
state={this.state}
|
||
|
isolateEntry={this.handleIsolateEntry}
|
||
|
handleInflectionSearch={this.handleInflectionSearch}
|
||
|
/>
|
||
|
</Route>
|
||
|
<Route path="/new-entries">
|
||
|
<h4 className="mb-3">
|
||
|
New Words This {capitalizeFirstLetter(newWordsPeriod)}
|
||
|
</h4>
|
||
|
{this.state.results.length ? (
|
||
|
<Results
|
||
|
state={this.state}
|
||
|
isolateEntry={this.handleIsolateEntry}
|
||
|
handleInflectionSearch={this.handleInflectionSearch}
|
||
|
/>
|
||
|
) : (
|
||
|
<div>No new words added this {newWordsPeriod}</div>
|
||
|
)}
|
||
|
</Route>
|
||
|
<Route path="/account">
|
||
|
<Account
|
||
|
user={this.state.user}
|
||
|
loadUser={this.handleLoadUser}
|
||
|
/>
|
||
|
</Route>
|
||
|
<Route path="/word">
|
||
|
<IsolatedEntry
|
||
|
state={this.state}
|
||
|
dictionary={dictionary}
|
||
|
isolateEntry={this.handleIsolateEntry}
|
||
|
/>
|
||
|
</Route>
|
||
|
<Route path="/wordlist">
|
||
|
<Wordlist
|
||
|
options={this.state.options}
|
||
|
wordlist={this.state.wordlist}
|
||
|
isolateEntry={this.handleIsolateEntry}
|
||
|
optionsDispatch={this.handleOptionsUpdate}
|
||
|
user={this.state.user}
|
||
|
loadUser={this.handleLoadUser}
|
||
|
/>
|
||
|
</Route>
|
||
|
<Route path="/script-to-phonetics">
|
||
|
<ScriptToPhonetics />
|
||
|
</Route>
|
||
|
{this.state.user?.level === "editor" && (
|
||
|
<Route path="/edit">
|
||
|
<EntryEditor
|
||
|
isolatedEntry={this.state.isolatedEntry}
|
||
|
user={this.state.user}
|
||
|
textOptions={getTextOptions(this.state)}
|
||
|
dictionary={dictionary}
|
||
|
searchParams={
|
||
|
new URLSearchParams(this.props.history.location.search)
|
||
|
}
|
||
|
/>
|
||
|
</Route>
|
||
|
)}
|
||
|
{this.state.user?.level === "editor" && (
|
||
|
<Route path="/review-tasks">
|
||
|
<ReviewTasks state={this.state} />
|
||
|
</Route>
|
||
|
)}
|
||
|
</>
|
||
|
)}
|
||
|
</div>
|
||
|
<footer
|
||
|
className={classNames(
|
||
|
"footer",
|
||
|
{
|
||
|
"bg-white": !["/search", "/word"].includes(
|
||
|
this.props.location.pathname
|
||
|
),
|
||
|
},
|
||
|
{
|
||
|
"footer-thick":
|
||
|
this.state.options.searchBarPosition === "bottom" &&
|
||
|
!["/search", "/word"].includes(this.props.location.pathname),
|
||
|
},
|
||
|
{
|
||
|
"wee-less-footer":
|
||
|
this.state.options.searchBarPosition === "bottom" &&
|
||
|
["/search", "/word"].includes(this.props.location.pathname),
|
||
|
}
|
||
|
)}
|
||
|
>
|
||
|
<Route path="/" exact={true}>
|
||
|
<div className="buttons-footer">
|
||
|
<BottomNavItem label="About" icon="info-circle" page="/about" />
|
||
|
<BottomNavItem label="Settings" icon="cog" page="/settings" />
|
||
|
<BottomNavItem
|
||
|
label={this.state.user ? "Account" : "Sign In"}
|
||
|
icon="user"
|
||
|
page="/account"
|
||
|
/>
|
||
|
<BottomNavItem
|
||
|
label={`Wordlist ${
|
||
|
this.state.options.wordlistReviewBadge
|
||
|
? textBadge(forReview(this.state.wordlist).length)
|
||
|
: ""
|
||
|
}`}
|
||
|
icon="list"
|
||
|
page="/wordlist"
|
||
|
/>
|
||
|
{this.state.user?.level === "editor" && (
|
||
|
<BottomNavItem
|
||
|
label={`Tasks ${textBadge(this.state.reviewTasks.length)}`}
|
||
|
icon="edit"
|
||
|
page="/review-tasks"
|
||
|
/>
|
||
|
)}
|
||
|
</div>
|
||
|
</Route>
|
||
|
<Route
|
||
|
path={[
|
||
|
"/about",
|
||
|
"/settings",
|
||
|
"/new-entries",
|
||
|
"/account",
|
||
|
"/wordlist",
|
||
|
"/edit",
|
||
|
"/review-tasks",
|
||
|
"/phrase-builder",
|
||
|
]}
|
||
|
>
|
||
|
<div className="buttons-footer">
|
||
|
<BottomNavItem label="Home" icon="home" page="/" />
|
||
|
</div>
|
||
|
</Route>
|
||
|
{this.state.options.searchBarPosition === "bottom" && (
|
||
|
<SearchBar
|
||
|
state={this.state}
|
||
|
optionsDispatch={this.handleOptionsUpdate}
|
||
|
handleSearchValueChange={this.handleSearchValueChange}
|
||
|
onBottom={true}
|
||
|
/>
|
||
|
)}
|
||
|
</footer>
|
||
|
</div>
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export default withRouter(App);
|