This commit is contained in:
adueck 2024-05-04 10:33:22 +04:00
parent ea21348c5c
commit eb9ffc5c6f
8 changed files with 1099 additions and 862 deletions

View File

@ -9,23 +9,26 @@ import ExtraEntryInfo from "../components/ExtraEntryInfo";
import classNames from "classnames";
import { Types as T, InlinePs } from "@lingdocs/ps-react";
import playStorageAudio from "./PlayStorageAudio";
import { LingdocsUser } from "../types/account-types";
function Entry({
entry,
textOptions,
nonClickable,
isolateEntry,
user,
}: {
entry: T.DictionaryEntry;
textOptions: T.TextOptions;
nonClickable?: boolean;
isolateEntry?: (ts: number) => void;
user: LingdocsUser | undefined;
}) {
function handlePlayStorageAudio(
e: React.MouseEvent<HTMLElement, MouseEvent>
) {
e.stopPropagation();
playStorageAudio(entry.ts, entry.p, () => null);
playStorageAudio(entry.ts, entry.p, user, () => null);
}
return (
<div

View File

@ -21,7 +21,7 @@ export function EntryAudioDisplay({
}
ReactGA.event({
category: "sounds",
action: `play ${entry.ts} - ${entry.p}`,
action: `play ${entry.p} - ${entry.ts}`,
});
}
return (

View File

@ -1,4 +1,5 @@
import ReactGA from "react-ga4";
import { LingdocsUser } from "../types/account-types";
export function getAudioPath(ts: number): string {
return `https://storage.lingdocs.com/audio/${ts}.mp3`;
@ -7,13 +8,16 @@ export function getAudioPath(ts: number): string {
export default function playStorageAudio(
ts: number,
p: string,
user: LingdocsUser | undefined,
callback: () => void
) {
if (!ts) return;
if (user && !user.admin) {
ReactGA.event({
category: "sounds",
action: `play ${ts} - ${p}`,
action: `quick play ${p} - ${ts}`,
});
}
let audio = new Audio(getAudioPath(ts));
audio.addEventListener("ended", () => {
callback();

View File

@ -21,16 +21,13 @@ import {
} from "@lingdocs/ps-react";
import Entry from "../components/Entry";
import * as FT from "../types/functions-types";
import {
submissionBase,
addSubmission,
} from "../lib/submissions";
import { submissionBase, addSubmission } from "../lib/submissions";
import { Helmet } from "react-helmet";
import { TextOptions } from "@lingdocs/ps-react/dist/types";
import * as AT from "../types/account-types";
import { DictionaryAPI } from "../types/dictionary-types";
const textFields: {field: T.DictionaryEntryTextField, label: string}[] = [
const textFields: { field: T.DictionaryEntryTextField; label: string }[] = [
{ field: "p", label: "Pashto" },
{ field: "f", label: "Phonetics" },
{ field: "e", label: "English" },
@ -57,7 +54,8 @@ const textFields: {field: T.DictionaryEntryTextField, label: string}[] = [
{ field: "ep", label: "English Verb Particle" },
];
const booleanFields: {field: T.DictionaryEntryBooleanField, label: string}[] = [
const booleanFields: { field: T.DictionaryEntryBooleanField; label: string }[] =
[
{ field: "noInf", label: "no inflection" },
{ field: "shortIntrans", label: "short intrans" },
{ field: "noOo", label: "no oo prefix" },
@ -65,21 +63,23 @@ const booleanFields: {field: T.DictionaryEntryBooleanField, label: string}[] = [
{ field: "diacExcept", label: "diacritics except." },
];
const numberFields: {field: T.DictionaryEntryNumberField, label: string}[] = [
const numberFields: { field: T.DictionaryEntryNumberField; label: string }[] = [
{ field: "l", label: "link" },
{ field: "separationAtP", label: "seperation at P" },
{ field: "separationAtF", label: "seperation at F" },
];
function OneField(props: {
value: string | number | undefined,
field: { field: T.DictionaryEntryField, label: string | JSX.Element },
errored?: boolean,
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void,
value: string | number | undefined;
field: { field: T.DictionaryEntryField; label: string | JSX.Element };
errored?: boolean;
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
return (
<div className="form-group">
<label htmlFor={props.field.field} className="small">{props.field.label}</label>
<label htmlFor={props.field.field} className="small">
{props.field.label}
</label>
<input
type="text"
id={props.field.field}
@ -96,35 +96,54 @@ function OneField(props: {
);
}
function EntryEditor({ isolatedEntry, dictionary, searchParams, textOptions, user }: {
isolatedEntry: T.DictionaryEntry | undefined,
textOptions: TextOptions,
dictionary: DictionaryAPI,
searchParams: URLSearchParams,
user: AT.LingdocsUser | undefined,
function EntryEditor({
isolatedEntry,
dictionary,
searchParams,
textOptions,
user,
}: {
isolatedEntry: T.DictionaryEntry | undefined;
textOptions: TextOptions;
dictionary: DictionaryAPI;
searchParams: URLSearchParams;
user: AT.LingdocsUser | undefined;
// removeFromSuggestions: (sTs: number) => void,
}) {
const [entry, setEntry] = useState<T.DictionaryEntry>((isolatedEntry) ?? { ts: 0, i: 0, p: "", f: "", g: "", e: "" });
const [matchingEntries, setMatchingEntries] = useState<T.DictionaryEntry[]>(isolatedEntry ? searchForMatchingEntries(isolatedEntry.p) : []);
const [erroneusFields, setErroneousFields] = useState<T.DictionaryEntryField[]>([]);
const [entry, setEntry] = useState<T.DictionaryEntry>(
isolatedEntry ?? { ts: 0, i: 0, p: "", f: "", g: "", e: "" }
);
const [matchingEntries, setMatchingEntries] = useState<T.DictionaryEntry[]>(
isolatedEntry ? searchForMatchingEntries(isolatedEntry.p) : []
);
const [erroneusFields, setErroneousFields] = useState<
T.DictionaryEntryField[]
>([]);
const [errors, setErrors] = useState<string[]>([]);
const [submitted, setSubmitted] = useState<boolean>(false);
const [deleted, setDeleted] = useState<boolean>(false);
const [willDeleteSuggestion, setWillDeleteSuggestion] = useState<boolean>(true);
const [willDeleteSuggestion, setWillDeleteSuggestion] =
useState<boolean>(true);
const comment = searchParams.get("comment");
const sTsString = searchParams.get("sTs");
const sTs = (sTsString && sTsString !== "0") ? parseInt(sTsString) : undefined;
const suggestedWord = (searchParams.get("p") || searchParams.get("f")) ? {
const sTs = sTsString && sTsString !== "0" ? parseInt(sTsString) : undefined;
const suggestedWord =
searchParams.get("p") || searchParams.get("f")
? {
p: searchParams.get("p") || "",
f: searchParams.get("f") || "",
} : undefined;
}
: undefined;
useEffect(() => {
setEntry((isolatedEntry) ?? { ts: 1, i: 0, p: "", f: "", g: "", e: "" });
setMatchingEntries(isolatedEntry ? searchForMatchingEntries(isolatedEntry.p) : []);
setEntry(isolatedEntry ?? { ts: 1, i: 0, p: "", f: "", g: "", e: "" });
setMatchingEntries(
isolatedEntry ? searchForMatchingEntries(isolatedEntry.p) : []
);
// eslint-disable-next-line
}, [isolatedEntry]);
function searchForMatchingEntries(s: string): T.DictionaryEntry[] {
return dictionary.exactPashtoSearch(s)
return dictionary
.exactPashtoSearch(s)
.filter((w) => w.ts !== isolatedEntry?.ts);
}
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
@ -133,7 +152,10 @@ function EntryEditor({ isolatedEntry, dictionary, searchParams, textOptions, use
const name = target.name;
setEntry({
...entry,
[name]: (value && numberFields.find((x) => x.field === name) && typeof value === "string")
[name]:
value &&
numberFields.find((x) => x.field === name) &&
typeof value === "string"
? parseInt(value)
: value,
});
@ -186,21 +208,48 @@ function EntryEditor({ isolatedEntry, dictionary, searchParams, textOptions, use
return false;
}
})();
const linkField: { field: "l", label: string | JSX.Element } = {
const linkField: { field: "l"; label: string | JSX.Element } = {
field: "l",
label: <>link {entry.l ? (complement ? <InlinePs opts={textOptions}>{complement}</InlinePs> : "not found") : ""}</>,
label: (
<>
link{" "}
{entry.l ? (
complement ? (
<InlinePs opts={textOptions}>{complement}</InlinePs>
) : (
"not found"
)
) : (
""
)}
</>
),
};
return <div className="width-limiter" style={{ marginBottom: "70px" }}>
return (
<div className="width-limiter" style={{ marginBottom: "70px" }}>
<Helmet>
<link rel="canonical" href="https://dictionary.lingdocs.com/edit" />
<title>Edit - LingDocs Pashto Dictionary</title>
</Helmet>
{isolatedEntry && <Entry nonClickable entry={isolatedEntry} textOptions={textOptions} isolateEntry={() => null} />}
{isolatedEntry && (
<Entry
user={user}
nonClickable
entry={isolatedEntry}
textOptions={textOptions}
isolateEntry={() => null}
/>
)}
{suggestedWord && <InlinePs opts={textOptions}>{suggestedWord}</InlinePs>}
{comment && <p>Comment: "{comment}"</p>}
{submitted ? "Edit submitted/saved" : deleted ? "Entry Deleted" :
{submitted ? (
"Edit submitted/saved"
) : deleted ? (
"Entry Deleted"
) : (
<div>
{matchingEntries.length > 0 && <div className="mb-1 text-center">
{matchingEntries.length > 0 && (
<div className="mb-1 text-center">
<strong>Matching Entries:</strong>
{matchingEntries.map((entry) => (
<div key={entry.ts}>
@ -209,7 +258,8 @@ function EntryEditor({ isolatedEntry, dictionary, searchParams, textOptions, use
</Link>
</div>
))}
</div>}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="row">
<div className="col">
@ -306,18 +356,35 @@ function EntryEditor({ isolatedEntry, dictionary, searchParams, textOptions, use
<input
id={field.field}
type="checkbox"
className={classNames("form-check-input", { "is-invalid": erroneusFields.includes(field.field) })}
className={classNames("form-check-input", {
"is-invalid": erroneusFields.includes(field.field),
})}
name={field.field}
checked={entry[field.field] || false}
onChange={handleInputChange}
/>
<label htmlFor={field.field} className="form-check-label">{field.label}</label>
<label htmlFor={field.field} className="form-check-label">
{field.label}
</label>
</div>
))}
<div className="form-group">
<button type="submit" className="btn btn-primary mr-4" onClick={handleSubmit}>Submit</button>
<button type="button" className="btn btn-danger" onClick={handleDelete}>Delete Entry</button>
{sTs && <div className="ml-3 form-group form-check-inline">
<button
type="submit"
className="btn btn-primary mr-4"
onClick={handleSubmit}
>
Submit
</button>
<button
type="button"
className="btn btn-danger"
onClick={handleDelete}
>
Delete Entry
</button>
{sTs && (
<div className="ml-3 form-group form-check-inline">
<input
id={"deleteSts"}
type="checkbox"
@ -326,22 +393,37 @@ function EntryEditor({ isolatedEntry, dictionary, searchParams, textOptions, use
checked={willDeleteSuggestion}
onChange={(e) => setWillDeleteSuggestion(e.target.checked)}
/>
<label htmlFor="deleteSts" className="form-check-label">Delete suggestion?</label>
</div>}
<label htmlFor="deleteSts" className="form-check-label">
Delete suggestion?
</label>
</div>
{errors.length > 0 && <div className="alert alert-warning">
)}
</div>
{errors.length > 0 && (
<div className="alert alert-warning">
<ul className="mt-2">
{errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
</div>}
</div>
)}
</form>
{inf && inf.inflections && <InflectionsTable inf={inf.inflections} textOptions={textOptions} />}
{inf && "plural" in inf && inf.plural !== undefined && <InflectionsTable inf={inf.plural} textOptions={textOptions} />}
{inf && "arabicPlural" in inf && inf.arabicPlural !== undefined && <InflectionsTable inf={inf.arabicPlural} textOptions={textOptions} />}
{inf && inf.inflections && (
<InflectionsTable inf={inf.inflections} textOptions={textOptions} />
)}
{inf && "plural" in inf && inf.plural !== undefined && (
<InflectionsTable inf={inf.plural} textOptions={textOptions} />
)}
{inf && "arabicPlural" in inf && inf.arabicPlural !== undefined && (
<InflectionsTable
inf={inf.arabicPlural}
textOptions={textOptions}
/>
)}
{/* TODO: aay tail from state options */}
{typePredicates.isVerbEntry({ entry, complement }) && <div className="pb-4">
{typePredicates.isVerbEntry({ entry, complement }) && (
<div className="pb-4">
<VPExplorer
verb={{
// TODO: CLEAN THIS UP!
@ -353,11 +435,14 @@ function EntryEditor({ isolatedEntry, dictionary, searchParams, textOptions, use
entryFeeder={entryFeeder}
handleLinkClick="none"
/>
</div>}
{typePredicates.isVerbEntry({ entry, complement }) && <div className="pb-4">
</div>
)}
{typePredicates.isVerbEntry({ entry, complement }) && (
<div className="pb-4">
{(() => {
try {
return <VPExplorer
return (
<VPExplorer
verb={{
// TODO: CLEAN THIS UP!
// @ts-ignore
@ -368,14 +453,18 @@ function EntryEditor({ isolatedEntry, dictionary, searchParams, textOptions, use
entryFeeder={entryFeeder}
handleLinkClick={"none"}
/>
);
} catch (e) {
console.error(e);
return <h5>Error conjugating verb</h5>
return <h5>Error conjugating verb</h5>;
}
})()}
</div>}
</div>}
</div>;
</div>
)}
</div>
)}
</div>
);
}
export default EntryEditor;

View File

@ -164,6 +164,7 @@ function IsolatedEntry({
<dl className="row mb-1">
<div className="col-8">
<Entry
user={state.user}
nonClickable
entry={exploded ? explodeEntry(entry) : entry}
textOptions={textOptions}

View File

@ -143,6 +143,7 @@ function Results({
entry={p.entry}
textOptions={textOptions}
isolateEntry={isolateEntry}
user={state.user}
/>
<div className="mb-3 ml-2">
{p.forms.map((form, i) => (
@ -169,6 +170,7 @@ function Results({
entry={entry}
textOptions={textOptions}
isolateEntry={isolateEntry}
user={state.user}
/>
))}
</dl>

View File

@ -1,68 +1,90 @@
import Entry from "../components/Entry";
import { Link } from "react-router-dom";
import * as FT from "../types/functions-types";
import {
deleteFromLocalDb,
} from "../lib/pouch-dbs";
import {
Types as T,
} from "@lingdocs/ps-react";
import { deleteFromLocalDb } from "../lib/pouch-dbs";
import { Types as T } from "@lingdocs/ps-react";
import { Helmet } from "react-helmet";
import { getTextOptions } from "../lib/get-text-options";
import {
State,
} from "../types/dictionary-types";
import { State } from "../types/dictionary-types";
function ReviewTask({ reviewTask, textOptions }: { reviewTask: FT.ReviewTask, textOptions: T.TextOptions }) {
function ReviewTask({
reviewTask,
textOptions,
}: {
reviewTask: FT.ReviewTask;
textOptions: T.TextOptions;
}) {
function handleDelete() {
deleteFromLocalDb("reviewTasks", reviewTask._id);
}
const queryParamData = {
...reviewTask.sTs ? {
...(reviewTask.sTs
? {
sTs: reviewTask.sTs,
} : {},
..."comment" in reviewTask ? {
}
: {}),
...("comment" in reviewTask
? {
comment: reviewTask.comment,
} : {},
..."entry" in reviewTask ? {
}
: {}),
...("entry" in reviewTask
? {
id: reviewTask.entry.ts,
} : {},
}
: {}),
} as URLSearchParams;
const queryString = new URLSearchParams(queryParamData).toString();
return <div className="d-flex flex-row align-items-center">
return (
<div className="d-flex flex-row align-items-center">
<div className="mr-3">
<div onClick={handleDelete} className="clickable">
<i className="fa fa-trash" />
</div>
</div>
{reviewTask.type !== "issue" &&
{reviewTask.type !== "issue" && (
<Link to={`/edit?${queryString}`} className="plain-link">
<div className="card mb-2">
<div className="card-body">
{reviewTask.type === "entry suggestion" && <div>
New Entry Suggestion
</div>}
<Entry textOptions={textOptions} entry={reviewTask.entry} />
{reviewTask.type === "entry suggestion" && (
<div>New Entry Suggestion</div>
)}
<Entry
user={undefined}
textOptions={textOptions}
entry={reviewTask.entry}
/>
<div className="mb-2">"{reviewTask.comment}"</div>
<div className="small">{reviewTask.user.name} - {reviewTask.user.email}</div>
<div className="small">
{reviewTask.user.name} - {reviewTask.user.email}
</div>
</div>
</div>
</Link>
}
)}
</div>
);
}
export default function ReviewTasks({ state }: { state: State }) {
const textOptions = getTextOptions(state);
return <div className="width-limiter" style={{ marginBottom: "70px" }}>
return (
<div className="width-limiter" style={{ marginBottom: "70px" }}>
<Helmet>
<title>Review Tasks - LingDocs Pashto Dictionary</title>
</Helmet>
<h3 className="mb-4">Review Tasks</h3>
{state.reviewTasks.length ?
state.reviewTasks.map((reviewTask, i) => <ReviewTask key={i} reviewTask={reviewTask} textOptions={textOptions} />)
: <p>None</p>
}
</div>;
{state.reviewTasks.length ? (
state.reviewTasks.map((reviewTask, i) => (
<ReviewTask
key={i}
reviewTask={reviewTask}
textOptions={textOptions}
/>
))
) : (
<p>None</p>
)}
</div>
);
}

View File

@ -19,15 +19,8 @@ import {
calculateWordsToDelete,
hasAttachment,
} from "../lib/wordlist-database";
import {
ButtonSelect,
InlinePs,
removeFVarients,
} from "@lingdocs/ps-react";
import {
Modal,
Button,
} from "react-bootstrap";
import { ButtonSelect, InlinePs, removeFVarients } from "@lingdocs/ps-react";
import { Modal, Button } from "react-bootstrap";
import WordlistImage from "../components/WordlistImage";
import ReviewScoreInput from "../components/ReviewScoreInput";
import { isPashtoScript } from "../lib/is-pashto";
@ -36,9 +29,7 @@ import {
nextUpForReview,
practiceWord,
} from "../lib/spaced-repetition";
import {
pageSize,
} from "../lib/dictionary";
import { pageSize } from "../lib/dictionary";
import { textBadge } from "../lib/badges";
import { SuperMemoGrade } from "supermemo";
import AudioPlayButton from "../components/AudioPlayButton";
@ -60,8 +51,8 @@ const cleanupIcon = "broom";
dayjs.extend(relativeTime);
const reviewLanguageOptions: {
label: string,
value: Language,
label: string;
value: Language;
}[] = [
{
label: "Pashto",
@ -86,27 +77,40 @@ function amountOfWords(number: number): string {
let popupRef: Window | null = null;
function Wordlist({ options, wordlist, isolateEntry, optionsDispatch, user, loadUser }: {
options: Options,
wordlist: WordlistWord[],
isolateEntry: (ts: number) => void,
optionsDispatch: (action: OptionsAction) => void,
user: AT.LingdocsUser | undefined,
loadUser: () => void,
function Wordlist({
options,
wordlist,
isolateEntry,
optionsDispatch,
user,
loadUser,
}: {
options: Options;
wordlist: WordlistWord[];
isolateEntry: (ts: number) => void;
optionsDispatch: (action: OptionsAction) => void;
user: AT.LingdocsUser | undefined;
loadUser: () => void;
}) {
// @ts-ignore
const [wordOpen, setWordOpen] = useState<string | undefined>(undefined);
const [wordQuizzing, setWordQuizzing] = useState<string | undefined>(undefined);
const [wordQuizzing, setWordQuizzing] = useState<string | undefined>(
undefined
);
const [showGuide, setShowGuide] = useState<boolean>(true);
const [showingDownload, setShowingDownload] = useState<boolean>(false);
const [wordlistDownloadSort, setWordlistDownloadSort] = useState<"alphabetical" | "time">("time");
const [wordlistDownloadSort, setWordlistDownloadSort] = useState<
"alphabetical" | "time"
>("time");
const [page, setPage] = useState<number>(1);
const [wordlistSearchValue, setWordlistSearchValue] = useState<string>("");
const [filteredWords, setFilteredWords] = useState<WordlistWord[]>([]);
const [showingCleanup, setShowingCleanup] = useState<boolean>(false);
const [monthsBackToKeep, setMonthsBackToKeep] = useState<number>(6);
const [wordsToDelete, setWordsToDelete] = useState<string[]>([]);
const [startedWithWordsToReview] = useState<boolean>(forReview(wordlist).length !== 0);
const [startedWithWordsToReview] = useState<boolean>(
forReview(wordlist).length !== 0
);
useEffect(() => {
window.addEventListener("message", handleIncomingMessage);
return () => {
@ -117,9 +121,9 @@ function Wordlist({ options, wordlist, isolateEntry, optionsDispatch, user, load
// TODO put the account url in an imported constant
function handleIncomingMessage(event: MessageEvent<any>) {
if (
event.origin === "https://account.lingdocs.com"
&& (event.data === "signed in")
&& popupRef
event.origin === "https://account.lingdocs.com" &&
event.data === "signed in" &&
popupRef
) {
loadUser();
popupRef.close();
@ -135,7 +139,7 @@ function Wordlist({ options, wordlist, isolateEntry, optionsDispatch, user, load
function handleScroll() {
// TODO: DON'T HAVE ENDLESS PAGE INCREASING
if (hitBottom() && options.wordlistMode === "browse") {
setPage(page => page + 1);
setPage((page) => page + 1);
}
}
function handleWordClickBrowse(id: string) {
@ -171,9 +175,7 @@ function Wordlist({ options, wordlist, isolateEntry, optionsDispatch, user, load
window.URL.revokeObjectURL(url);
}
function handleShowCleanup() {
setWordsToDelete(
calculateWordsToDelete(wordlist, monthsBackToKeep)
);
setWordsToDelete(calculateWordsToDelete(wordlist, monthsBackToKeep));
setShowingCleanup(true);
}
function handleCloseCleanup() {
@ -186,103 +188,140 @@ function Wordlist({ options, wordlist, isolateEntry, optionsDispatch, user, load
setShowingCleanup(false);
}
function handleOpenSignup() {
popupRef = window.open("https://account.lingdocs.com", "account", "height=800,width=500,top=50,left=400");
popupRef = window.open(
"https://account.lingdocs.com",
"account",
"height=800,width=500,top=50,left=400"
);
}
function WordlistBrowsingWord({ word }: { word: WordlistWord }) {
const [confirmDelete, setConfirmDelete] = useState<boolean>(false);
return <div className="mb-4">
return (
<div className="mb-4">
<Entry
user={user}
entry={word.entry}
textOptions={textOptions}
isolateEntry={() => handleWordClickBrowse(word._id)}
/>
{hasAttachment(word, "audio") && <AudioPlayButton word={word} />}
{word._id === wordOpen ? <>
{word._id === wordOpen ? (
<>
<div className="mb-3 d-flex flex-row justify-content-between">
<div>
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => isolateEntry(word.entry.ts)}>
<i className="fas fa-book"/>{` `}View Entry
onClick={() => isolateEntry(word.entry.ts)}
>
<i className="fas fa-book" />
{` `}View Entry
</button>
</div>
<div>
{!confirmDelete ?
{!confirmDelete ? (
<button
className="btn btn-sm btn-outline-danger"
onClick={() => setConfirmDelete(true)}
>
<i className="fas fa-trash"/>{` `}Delete
<i className="fas fa-trash" />
{` `}Delete
</button>
:
) : (
<div>
<button
className="btn mr-2 btn-sm btn-outline-secondary"
onClick={() => setConfirmDelete(false)}
>
<i className="fas fa-times"/>{` `}Cancel
<i className="fas fa-times" />
{` `}Cancel
</button>
<button
className="btn btn-sm btn-outline-danger"
onClick={() => deleteWord(word._id)}
>
<i className="fas fa-check"/>{` `}Delete
<i className="fas fa-check" />
{` `}Delete
</button>
</div>
}
)}
</div>
</div>
{(Date.now() < word.dueDate) && <div className="text-muted small text-center mt-2 mb-1">
{Date.now() < word.dueDate && (
<div className="text-muted small text-center mt-2 mb-1">
up for review {dayjs().to(dayjs(word.dueDate))}
</div>}
</div>
)}
{/* possible "next review" in x days, mins etc. calculated from milliseconds */}
<WordlistWordEditor word={word} />
</>
:
word.notes && <div
) : (
word.notes && (
<div
className="clickable text-muted mb-3"
onClick={() => handleWordClickBrowse(word._id)}
dir="auto"
style={{ textAlign: isPashtoScript(word.notes[0]) ? "right" : "left" }}
style={{
textAlign: isPashtoScript(word.notes[0]) ? "right" : "left",
}}
>
{word.notes}
</div>
}
{(hasAttachment(word, "image") && word._id !== wordOpen) && <WordlistImage word={word} />}
</div>;
)
)}
{hasAttachment(word, "image") && word._id !== wordOpen && (
<WordlistImage word={word} />
)}
</div>
);
}
function WordlistReviewWord({ word }: { word: WordlistWord }) {
const beingQuizzed = word._id === wordQuizzing;
return <div className="mb-4">
<div className="card mb-3 clickable" onClick={() => handleWordClickReview(word._id)}>
return (
<div className="mb-4">
<div
className="card mb-3 clickable"
onClick={() => handleWordClickReview(word._id)}
>
<div className="card-body">
<h6 className="card-title text-center">
{options.wordlistReviewLanguage === "Pashto"
? <InlinePs opts={textOptions}>{{ p: word.entry.p, f: word.entry.f }}</InlinePs>
: word.entry.e
}
</h6>
{beingQuizzed && <div className="card-text text-center">
{options.wordlistReviewLanguage === "Pashto"
? <div>{word.entry.e}</div>
: <InlinePs opts={textOptions}>
{options.wordlistReviewLanguage === "Pashto" ? (
<InlinePs opts={textOptions}>
{{ p: word.entry.p, f: word.entry.f }}
</InlinePs>
}
) : (
word.entry.e
)}
</h6>
{beingQuizzed && (
<div className="card-text text-center">
{options.wordlistReviewLanguage === "Pashto" ? (
<div>{word.entry.e}</div>
) : (
<InlinePs opts={textOptions}>
{{ p: word.entry.p, f: word.entry.f }}
</InlinePs>
)}
<div className="text-muted mb-2">{word.notes}</div>
{hasAttachment(word, "audio") && <AudioPlayButton word={word} />}
{hasAttachment(word, "audio") && (
<AudioPlayButton word={word} />
)}
{hasAttachment(word, "image") && <WordlistImage word={word} />}
</div>}
</div>
)}
</div>
</div>
{beingQuizzed && <ReviewScoreInput
{beingQuizzed && (
<ReviewScoreInput
handleGrade={(grade) => handleAnswer(word, grade)}
guide={showGuide}
/>}
/>
)}
</div>
);
}
if (!user || user.level === "basic") {
return <div className="width-limiter" style={{ marginBottom: "120px" }}>
return (
<div className="width-limiter" style={{ marginBottom: "120px" }}>
<Helmet>
<title>Wordlist - LingDocs Pashto Dictionary</title>
</Helmet>
@ -290,9 +329,16 @@ function Wordlist({ options, wordlist, isolateEntry, optionsDispatch, user, load
<h4 className="mb-3">Wordlist</h4>
</div>
<div style={{ marginTop: "2rem" }}>
{!user
? <p className="lead"><Link to="/account">Sign in</Link> to upgrade and enable wordlist</p>
: <p className="lead">Upgrade to a <strong>student account</strong> to enable the wordlist</p>}
{!user ? (
<p className="lead">
<Link to="/account">Sign in</Link> to upgrade and enable wordlist
</p>
) : (
<p className="lead">
Upgrade to a <strong>student account</strong> to enable the
wordlist
</p>
)}
<div>
<p>Features:</p>
<ul>
@ -300,41 +346,62 @@ function Wordlist({ options, wordlist, isolateEntry, optionsDispatch, user, load
<li>Save text, audio, or visual context for words</li>
<li>Review words with Anki-style spaced repetition</li>
</ul>
{!user ? <>
{!user ? (
<>
<p>Cost:</p>
<ul>
<li>$1/month or $10/year - cancel any time</li>
</ul>
</> : <UpgradePrices source="wordlist" />}
{!user && <button className="btn btn-lg btn-primary my-4" onClick={handleOpenSignup}><i className="fas fa-sign-in-alt mr-2" /> Sign In</button>}
</>
) : (
<UpgradePrices source="wordlist" />
)}
{!user && (
<button
className="btn btn-lg btn-primary my-4"
onClick={handleOpenSignup}
>
<i className="fas fa-sign-in-alt mr-2" /> Sign In
</button>
)}
</div>
</div>
</div>;
</div>
);
}
return <div className="width-limiter" style={{ marginBottom: "120px" }}>
return (
<div className="width-limiter" style={{ marginBottom: "120px" }}>
<Helmet>
<title>Wordlist - LingDocs Pashto Dictionary</title>
</Helmet>
<div className="d-flex flex-row justify-content-between mb-2">
<h4 className="mb-3">Wordlist</h4>
{wordlist.length > 0 &&
{wordlist.length > 0 && (
<div className="d-flex flex-row justify-content-between mb-2">
<div>
<button className="btn btn-sm btn-outline-secondary mr-3" onClick={handleShowCleanup}>
<i className={`fas fa-${cleanupIcon} mx-1`} /> <span className="show-on-desktop">Cleanup</span>
<button
className="btn btn-sm btn-outline-secondary mr-3"
onClick={handleShowCleanup}
>
<i className={`fas fa-${cleanupIcon} mx-1`} />{" "}
<span className="show-on-desktop">Cleanup</span>
</button>
</div>
<div>
<button className="btn btn-sm btn-outline-secondary" onClick={() => setShowingDownload(true)}>
<i className="fas fa-download mx-1" /> <span className="show-on-desktop">CSV</span>
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => setShowingDownload(true)}
>
<i className="fas fa-download mx-1" />{" "}
<span className="show-on-desktop">CSV</span>
</button>
</div>
</div>
}
)}
</div>
{!wordlist.length ?
{!wordlist.length ? (
<EmptyWordlistNotice />
:
) : (
<>
<div className="mb-3 text-center">
<ButtonSelect
@ -350,62 +417,91 @@ function Wordlist({ options, wordlist, isolateEntry, optionsDispatch, user, load
]}
value={options.wordlistMode || "browse"}
handleChange={(p) => {
optionsDispatch({ type: "changeWordlistMode", payload: p as WordlistMode });
optionsDispatch({
type: "changeWordlistMode",
payload: p as WordlistMode,
});
}}
/>
</div>
{options.wordlistMode === "browse"
? <div className="mt-4">
<WordlistSearchBar value={wordlistSearchValue} handleChange={handleSearchValueChange} />
{paginate(wordlistSearchValue ? filteredWords : wordlist, page).map((word) => (
{options.wordlistMode === "browse" ? (
<div className="mt-4">
<WordlistSearchBar
value={wordlistSearchValue}
handleChange={handleSearchValueChange}
/>
{paginate(
wordlistSearchValue ? filteredWords : wordlist,
page
).map((word) => (
<WordlistBrowsingWord word={word} key={word._id} />
))}
</div>
: <div>
) : (
<div>
<div className="mb-2 text-center">Show:</div>
<div className="mb-4 text-center" style={{ width: "100%" }}>
<ButtonSelect
options={reviewLanguageOptions}
value={options.wordlistReviewLanguage || "Pashto"}
handleChange={(p) => {
optionsDispatch({ type: "changeWordlistReviewLanguage", payload: p as Language });
optionsDispatch({
type: "changeWordlistReviewLanguage",
payload: p as Language,
});
}}
/>
</div>
<div>
{/* TODO: ISSUE WITH NOT USING PAGINATE HERE BECAUSE OF IMAGE RELOADING BUGINESS WHEN HITTING BOTTOM */}
{toReview.length === 0
? (startedWithWordsToReview
? <p className="lead my-3">All done review 🎉</p>
: (() => {
{toReview.length === 0 ? (
startedWithWordsToReview ? (
<p className="lead my-3">All done review 🎉</p>
) : (
(() => {
const nextUp = nextUpForReview(wordlist);
const { e, ...ps } = nextUp.entry;
return <div>
return (
<div>
<div className="lead my-3">None to review</div>
<p>Next word up for review <strong>{dayjs().to(nextUp.dueDate)}</strong>: <InlinePs opts={textOptions}>
<p>
Next word up for review{" "}
<strong>{dayjs().to(nextUp.dueDate)}</strong>:{" "}
<InlinePs opts={textOptions}>
{removeFVarients(ps)}
</InlinePs></p>
</div>;
</InlinePs>
</p>
</div>
);
})()
)
: toReview.map((word) => (
) : (
toReview.map((word) => (
<WordlistReviewWord word={word} key={word._id} />
))
}
)}
</div>
</div>
}
{(wordlistSearchValue && filteredWords.length === 0) && <div>
)}
{wordlistSearchValue && filteredWords.length === 0 && (
<div>
<h6 className="my-4 ml-1">None found</h6>
</div>}
</div>
)}
</>
}
)}
<Modal show={showingDownload} onHide={() => setShowingDownload(false)}>
<Modal.Header closeButton>
<Modal.Title><i className={`fas fa-download mr-1`} /> Download Wordlist CSV</Modal.Title>
<Modal.Title>
<i className={`fas fa-download mr-1`} /> Download Wordlist CSV
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>You can download your wordlist in CSV format. Then you can open it with a spreadsheet program or import it into Anki. Pictures will not be included.</p>
<p>
You can download your wordlist in CSV format. Then you can open it
with a spreadsheet program or import it into Anki. Pictures will not
be included.
</p>
<p>How should we sort your wordlist?</p>
<ButtonSelect
options={[
@ -435,7 +531,9 @@ function Wordlist({ options, wordlist, isolateEntry, optionsDispatch, user, load
</Modal>
<Modal show={showingCleanup} onHide={handleCloseCleanup}>
<Modal.Header closeButton>
<Modal.Title><i className={`fas fa-${cleanupIcon} mr-1`} /> Wordlist Cleanup</Modal.Title>
<Modal.Title>
<i className={`fas fa-${cleanupIcon} mr-1`} /> Wordlist Cleanup
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>You have {amountOfWords(wordlist.length)} in your wordlist.</p>
@ -462,38 +560,52 @@ function Wordlist({ options, wordlist, isolateEntry, optionsDispatch, user, load
value={monthsBackToKeep.toString()}
handleChange={(p: string) => {
const months = parseInt(p);
setWordsToDelete(
calculateWordsToDelete(wordlist, months),
);
setWordsToDelete(calculateWordsToDelete(wordlist, months));
setMonthsBackToKeep(months);
}}
/>
<p className="mt-3">This will delete {amountOfWords(wordsToDelete.length)}.</p>
<p className="mt-3">
This will delete {amountOfWords(wordsToDelete.length)}.
</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleCloseCleanup}>
Cancel
</Button>
<Button variant="danger" onClick={handleCleanup} disabled={wordsToDelete.length === 0}>
<Button
variant="danger"
onClick={handleCleanup}
disabled={wordsToDelete.length === 0}
>
<i className={`fas fa-${cleanupIcon} mr-1`} /> Delete
</Button>
</Modal.Footer>
</Modal>
</div>
);
}
function EmptyWordlistNotice() {
return <div className="pt-4">
return (
<div className="pt-4">
<p>Your wordlist is empty.</p>
<p>To add a word to your wordlist, choose a word while searching and click on the <i className="far fa-star"/> icon.</p>
</div>;
<p>
To add a word to your wordlist, choose a word while searching and click
on the <i className="far fa-star" /> icon.
</p>
</div>
);
}
function WordlistSearchBar({ handleChange, value }: {
value: string,
handleChange: (value: string) => void,
function WordlistSearchBar({
handleChange,
value,
}: {
value: string;
handleChange: (value: string) => void;
}) {
return <div className="input-group mb-3">
return (
<div className="input-group mb-3">
<input
type="text"
style={{ borderRight: "0px", zIndex: 200 }}
@ -517,10 +629,14 @@ function WordlistSearchBar({ handleChange, value }: {
onClick={() => handleChange("")}
data-testid="wordlistClearButton"
>
<i className="fa fa-times" style={!value ? { visibility: "hidden" } : {}}></i>
<i
className="fa fa-times"
style={!value ? { visibility: "hidden" } : {}}
></i>
</span>
</span>
</div>;
</div>
);
}
export default Wordlist;