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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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