From f07ab92cfc5af5dd1f1635d6ce56532d2a9766a5 Mon Sep 17 00:00:00 2001 From: Matthew Grove Date: Wed, 1 Sep 2021 17:29:15 +0100 Subject: [PATCH] Add pages --- src/CreateSet.js | 269 +++++++++++++++++++ src/EditSet.js | 335 ++++++++++++++++++++++++ src/GroupPage.js | 577 ++++++++++++++++++++++++++++++++++++++++ src/History.js | 199 ++++++++++++++ src/Home.js | 34 +++ src/LoggedInHome.js | 434 +++++++++++++++++++++++++++++++ src/Login.js | 44 ++++ src/Progress.js | 622 ++++++++++++++++++++++++++++++++++++++++++++ src/SetPage.js | 370 ++++++++++++++++++++++++++ src/Settings.js | 97 +++++++ src/UserGroups.js | 347 ++++++++++++++++++++++++ src/UserSets.js | 95 +++++++ 12 files changed, 3423 insertions(+) create mode 100644 src/CreateSet.js create mode 100644 src/EditSet.js create mode 100644 src/GroupPage.js create mode 100644 src/History.js create mode 100644 src/Home.js create mode 100644 src/LoggedInHome.js create mode 100644 src/Login.js create mode 100644 src/Progress.js create mode 100644 src/SetPage.js create mode 100644 src/Settings.js create mode 100644 src/UserGroups.js create mode 100644 src/UserSets.js diff --git a/src/CreateSet.js b/src/CreateSet.js new file mode 100644 index 0000000..7f5f4c5 --- /dev/null +++ b/src/CreateSet.js @@ -0,0 +1,269 @@ +import React from 'react'; +import { withRouter } from 'react-router-dom'; +import NavBar from "./NavBar"; +import Button from "./Button"; +import Footer from "./Footer"; +import { HomeRounded as HomeRoundedIcon, CheckCircleOutlineRounded as CheckCircleOutlineRoundedIcon } from "@material-ui/icons"; +import Checkbox from '@material-ui/core/Checkbox'; + +// import { doc, collection, setDoc, writeBatch } from "firebase/firestore"; + +import "./css/Form.css"; + +export default withRouter(class CreateSet extends React.Component { + constructor(props) { + super(props); + this.state = { + user: props.user, + db: props.db, + loading: false, + canCreateSet: false, + inputContents: [], + inputs: { + title: "", + public: false, + }, + navbarItems: [ + { + type: "link", + link: "/", + icon: , + hideTextMobile: true, + } + ], + }; + + let isMounted = true; + Object.defineProperty(this, "isMounted", { + get: () => isMounted, + set: (value) => isMounted = value, + }); + } + + setState = (state, callback = null) => { + if (this.isMounted) super.setState(state, callback); + } + + componentDidMount() { + document.title = "Create Set | Parandum"; + this.setNameInput.focus(); + } + + componentWillUnmount() { + this.isMounted = false; + } + + stopLoading = () => { + this.setState({ + canCreateSet: true, + loading: false, + }); + } + + cleanseVocabString = (item) => { + const chars = " .,()-_'\""; + + let newString = item; + + chars.split("").forEach((char) => { + newString = newString.replace(char, ""); + }); + + return newString; + } + + handleSetDataChange = () => { + const numberOfVocabPairs = this.state.inputContents.map(contents => + this.cleanseVocabString(contents.term) !== "" && + this.cleanseVocabString(contents.definition) !== "") + .filter(x => x === true) + .length; + + if (this.state.inputs.title !== "" && numberOfVocabPairs > 0 && numberOfVocabPairs === this.state.inputContents.length) { + this.setState({ + canCreateSet: true, + }) + } else { + this.setState({ + canCreateSet: false, + }) + } + } + + onTermInputChange = (event) => { + const index = Number(event.target.name.replace("term_", "")); + const input = event.target.value; + + let inputContents = this.state.inputContents; + + if (index >= this.state.inputContents.length && input !== "") { + inputContents.push({ + term: input, + definition: "", + }); + } else { + if (index === this.state.inputContents.length - 1 && input === "" && this.state.inputContents[index].definition === "") { + inputContents.splice(-1); + } else { + inputContents[index].term = input; + } + } + + this.setState({ + inputContents: inputContents, + }, this.handleSetDataChange()); + } + + onDefinitionInputChange = (event) => { + const index = Number(event.target.name.replace("definition_", "")); + const input = event.target.value; + + let inputContents = this.state.inputContents; + + if (index >= this.state.inputContents.length && input !== "") { + inputContents.push({ + term: "", + definition: input, + }); + } else { + if (index === this.state.inputContents.length - 1 && input === "" && this.state.inputContents[index].term === "") { + inputContents.splice(-1); + } else { + inputContents[index].definition = input; + } + } + + this.setState({ + inputContents: inputContents, + }, this.handleSetDataChange()); + } + + onSetTitleInputChange = (event) => { + this.setState({ + inputs: { + ...this.state.inputs, + title: event.target.value, + } + }, this.handleSetDataChange()); + } + + onPublicSetInputChange = (event) => { + this.setState({ + inputs: { + ...this.state.inputs, + public: event.target.checked, + } + }); + } + + createSet = () => { + if (this.state.canCreateSet) { + this.setState({ + loading: true, + canCreateSet: false, + }); + + const db = this.state.db; + const setDocRef = db.collection("sets").doc(); + // const setDocRef = doc(collection(this.state.db, "sets")); + + setDocRef.set({ + title: this.state.inputs.title, + public: this.state.inputs.public, + owner: this.state.user.uid, + groups: [], + }) + // setDoc(setDocRef, { + // title: this.state.inputs.title, + // public: this.state.inputs.public, + // owner: this.state.user.uid, + // groups: [], + // }) + .then(() => { + const batch = db.batch(); + // const batch = writeBatch(this.state.db); + + this.state.inputContents.map((contents) => { + const vocabDocRef = setDocRef.collection("vocab").doc(); + // const vocabDocRef = doc(collection(setDocRef, "vocab")); + return batch.set(vocabDocRef, { + term: contents.term, + definition: contents.definition, + sound: false, + }) + }) + + // TODO: sound files + + batch.commit().then(() => { + this.stopLoading(); + this.props.history.push("/sets/" + setDocRef.id); + }).catch((e) => { + console.log("Couldn't create set. Batch commit error: " + e); + this.stopLoading(); + }) + }); + } + } + + render() { + return ( +
+ + +
+

+ (this.setNameInput = inputEl)} + /> +

+ +
+ + +
+

Terms

+

Definitions

+
+ + {this.state.inputContents.concat({term: "", definition: ""}).map((contents, index) => +
+ + +
+ )} +
+ +
+
+
+ ) + } +}) \ No newline at end of file diff --git a/src/EditSet.js b/src/EditSet.js new file mode 100644 index 0000000..d279ae4 --- /dev/null +++ b/src/EditSet.js @@ -0,0 +1,335 @@ +import React, { Component } from 'react'; +import { withRouter, Prompt } from "react-router-dom"; +import { HomeRounded as HomeRoundedIcon } from "@material-ui/icons"; +import NavBar from "./NavBar"; +import Button from "./Button"; +import Error404 from "./Error404"; +import Footer from "./Footer"; +import Checkbox from '@material-ui/core/Checkbox'; + +export default withRouter(class EditSet extends Component { + constructor(props) { + super(props); + this.state = { + user: props.user, + db: props.db, + loading: false, + canSaveSet: true, + inputs: { + title: "", + public: false, + }, + inputContents: [], + setInaccessible: false, + navbarItems: [ + { + type: "link", + link: "/", + icon: , + hideTextMobile: true, + } + ], + canMakeSetNonPublic: true, + }; + + let isMounted = true; + Object.defineProperty(this, "isMounted", { + get: () => isMounted, + set: (value) => isMounted = value, + }); + } + + setState = (state, callback = null) => { + if (this.isMounted) super.setState(state, callback); + } + + alertLeavingWithoutSaving = (e = null) => { + if (this.state.canSaveSet) { + var confirmationMessage = "Are you sure you want to leave? You will lose any unsaved changes."; + + (e || window.event).returnValue = confirmationMessage; //Gecko + IE + return confirmationMessage; //Gecko + Webkit, Safari, Chrome etc. + } + return ""; + } + + componentDidMount() { + window.addEventListener("beforeunload", this.alertLeavingWithoutSaving); + + const setId = this.props.match.params.setId; + const setRef = this.state.db.collection("sets") + .doc(setId); + const setVocabRef = setRef.collection("vocab") + .orderBy("term"); + + setRef.get().then((setDoc) => { + document.title = `Edit | ${setDoc.data().title} | Parandum`; + + setVocabRef.get().then((querySnapshot) => { + let vocab = []; + let vocabPairsCount = 0; + + querySnapshot.docs.map((doc) => { + const data = doc.data(); + + if (this.cleanseVocabString(data.term) !== "" && + this.cleanseVocabString(data.definition) !== "") { + vocabPairsCount++; + } + + return vocab.push({ + vocabId: doc.id, + term: data.term, + definition: data.definition, + sound: data.sound, + }); + }); + + let newState = { + inputs: { + title: setDoc.data().title, + public: setDoc.data().public, + }, + inputContents: vocab, + canMakeSetNonPublic: !(setDoc.data().groups && setDoc.data().groups.length > 0), + }; + + if (!(newState.inputs.title !== "" && vocabPairsCount > 0 && vocabPairsCount === this.state.inputContents.length)) { + newState.canSaveSet = false; + } + + if (setDoc.data().owner !== this.state.user.uid) { + newState.setInaccessible = true; + } + + this.setState(newState); + }); + }).catch(() => { + this.setState({ + setInaccessible: true, + }); + }); + } + + componentWillUnmount = () => { + window.removeEventListener('beforeunload', this.alertLeavingWithoutSaving); + this.isMounted = false; + } + + stopLoading = () => { + this.setState({ + canStartTest: true, + loading: false, + }); + } + + cleanseVocabString = (item) => { + const chars = " .,()-_'\""; + + let newString = item; + + chars.split("").forEach((char) => { + newString = newString.replace(char, ""); + }); + + return newString; + } + + handleSetDataChange = () => { + const numberOfVocabPairs = this.state.inputContents.map(contents => + this.cleanseVocabString(contents.term) !== "" && + this.cleanseVocabString(contents.definition) !== "") + .filter(x => x === true) + .length; + + if (this.state.inputs.title !== "" && numberOfVocabPairs > 0 && numberOfVocabPairs === this.state.inputContents.length) { + this.setState({ + canSaveSet: true, + }) + } else { + this.setState({ + canSaveSet: false, + }) + } + } + + onTermInputChange = (event) => { + const index = Number(event.target.name.replace("term_", "")); + const input = event.target.value; + + let inputContents = this.state.inputContents; + + if (index >= this.state.inputContents.length && input !== "") { + inputContents.push({ + term: input, + definition: "", + sound: false, + }); + } else { + if (index === this.state.inputContents.length - 1 && input === "" && this.state.inputContents[index].definition === "") { + inputContents.splice(-1); + } else { + inputContents[index].term = input; + } + } + + this.setState({ + inputContents: inputContents, + }, this.handleSetDataChange()); + } + + onDefinitionInputChange = (event) => { + const index = Number(event.target.name.replace("definition_", "")); + const input = event.target.value; + + let inputContents = this.state.inputContents; + + if (index >= this.state.inputContents.length && input !== "") { + inputContents.push({ + term: "", + definition: input, + sound: false, + }); + } else { + if (index === this.state.inputContents.length - 1 && input === "" && this.state.inputContents[index].term === "") { + inputContents.splice(-1); + } else { + inputContents[index].definition = input; + } + } + + this.setState({ + inputContents: inputContents, + }, this.handleSetDataChange()); + } + + onSetTitleInputChange = (event) => { + this.setState({ + inputs: { + ...this.state.inputs, + title: event.target.value, + } + }, this.handleSetDataChange()); + } + + onPublicSetInputChange = (event) => { + if (this.state.canMakeSetNonPublic) this.setState({ + inputs: { + ...this.state.inputs, + public: event.target.checked, + } + }, this.handleSetDataChange()); + } + + saveSet = () => { + if (this.state.canSaveSet) { + this.setState({ + loading: true, + canSaveSet: false, + }); + + const db = this.state.db; + const setId = this.props.match.params.setId; + const setDocRef = db.collection("sets").doc(setId); + + setDocRef.update({ + title: this.state.inputs.title, + public: this.state.inputs.public, + }).then(() => { + const batch = db.batch(); + + this.state.inputContents.map((contents) => { + const vocabDocRef = setDocRef.collection("vocab").doc(contents.vocabId); + return batch.set(vocabDocRef, { + term: contents.term, + definition: contents.definition, + sound: contents.sound, + }) + }) + + // TODO: sound files + + batch.commit().then(() => { + this.stopLoading(); + this.props.history.push("/sets/" + setDocRef.id); + }).catch((e) => { + console.log("Couldn't update set. Batch commit error: " + e); + this.stopLoading(); + }) + }); + } + } + + render() { + return ( + this.state.setInaccessible + ? + + : +
+ + + + +
+
+

+ +

+ +
+ +
+ + +
+

Terms

+

Definitions

+
+ + {this.state.inputContents.concat({ term: "", definition: "" }).map((contents, index) => +
+ + +
+ )} +
+
+
+
+ ) + } +}) diff --git a/src/GroupPage.js b/src/GroupPage.js new file mode 100644 index 0000000..26e746f --- /dev/null +++ b/src/GroupPage.js @@ -0,0 +1,577 @@ +import React, { Component } from 'react'; +import { withRouter, Link } from "react-router-dom"; +import { HomeRounded as HomeRoundedIcon, EditRounded as EditRoundedIcon, ArrowForwardRounded as ArrowForwardRoundedIcon, DeleteRounded as DeleteRoundedIcon } from "@material-ui/icons"; +import NavBar from "./NavBar"; +import Button from "./Button"; +import Footer from "./Footer"; + +import "./css/GroupPage.css"; +import "./css/ConfirmationDialog.css"; + +import Loader from "./puff-loader.svg" + +export default withRouter(class GroupPage extends Component { + constructor(props) { + super(props); + this.state = { + user: props.user, + db: props.db, + functions: { + removeSetFromGroup: props.functions.httpsCallable("removeSetFromGroup"), + getGroupMembers: props.functions.httpsCallable("getGroupMembers"), + }, + navbarItems: [ + { + type: "link", + link: "/", + icon: , + hideTextMobile: true, + } + ], + role: null, + groupName: "", + sets: {}, + memberCount: null, + joinCode: "", + editGroupName: false, + loading: false, + groupUsers: { + owners: [], + contributors: [], + members: [], + }, + editingUser: null, + showDeleteGroup: false, + deleteGroupLoading: false, + }; + + let isMounted = true; + Object.defineProperty(this, "isMounted", { + get: () => isMounted, + set: (value) => isMounted = value, + }); + } + + setState = (state, callback = null) => { + if (this.isMounted) super.setState(state, callback); + } + + componentDidMount() { + this.state.db + .collection("users") + .doc(this.state.user.uid) + .collection("groups") + .doc(this.props.match.params.groupId) + .get() + .then((userGroupDoc) => { + this.state.db + .collection("groups") + .doc(this.props.match.params.groupId) + .get() + .then(async (groupDoc) => { + document.title = `${groupDoc.data().display_name} | Parandum`; + + let newState = { + role: userGroupDoc.data().role, + groupName: groupDoc.data().display_name, + originalGroupName: groupDoc.data().display_name, + sets: {}, + memberCount: Object.keys(groupDoc.data().users).length + (Object.keys(groupDoc.data().users).includes(this.state.user.uid) ? 0 : 1), + joinCode: userGroupDoc.data().role === "owner" ? groupDoc.data().join_code : "", + }; + + await Promise.all(groupDoc.data().sets.map((setId) => { + return this.state.db.collection("sets") + .doc(setId) + .get() + .then((doc) => { + newState.sets[setId] = { + displayName: doc.data().title, + loading: false, + }; + }); + })); + + if (newState.role === "owner") { + var counter = 0; + const getGroupMembers = () => { + return this.state.functions.getGroupMembers({ groupId: this.props.match.params.groupId }) + .catch((error) => { + return { + data: { + owners: [ + { + displayName: this.state.user.displayName, + uid: this.state.user.uid, + } + ], + contributors: [], + members: [], + } + } + }); + } + + const groupUsers = await getGroupMembers(); + + newState.groupUsers = groupUsers.data; + } + + this.setState(newState); + }); + }); + } + + componentWillUnmount() { + this.isMounted = false; + } + + editGroupName = () => { + this.setState({ + editGroupName: true, + }, () => this.groupNameInput.focus()); + } + + handleGroupNameChange = (event) => { + this.setState({ + groupName: event.target.value, + }); + } + + handleInputKeypress = (event) => { + if (event.key === "Enter") { + this.renameGroup(); + } else if (event.key === "Escape") { + this.cancelGroupRename(); + } + } + + stopLoading = () => { + this.setState({ + loading: false, + editGroupName: false, + }) + } + + cancelGroupRename = () => { + this.setState({ + editGroupName: false, + groupName: this.state.originalGroupName, + }) + } + + renameGroup = () => { + if (!this.state.loading && this.state.groupName.replace(" ", "") !== "") { + if (this.state.groupName.trim() === this.state.originalGroupName) { + this.cancelGroupRename(); + } else { + this.setState({ + loading: true, + }); + + this.state.db.collection("groups") + .doc(this.props.match.params.groupId) + .update({ + display_name: this.state.groupName.trim(), + }).then(() => { + this.stopLoading(); + }).catch((error) => { + console.log(`Couldn't update group name: ${error}`); + this.setState({ + loading: false, + groupName: this.state.originalGroupName, + editGroupName: false, + }); + }); + } + } + } + + removeSet = (setId) => { + let newLoadingState = { + sets: this.state.sets, + }; + newLoadingState.sets[setId].loading = true; + this.setState(newLoadingState); + + this.state.functions.removeSetFromGroup({ + groupId: this.props.match.params.groupId, + setId: setId, + }).then(() => { + let newState = { + sets: this.state.sets, + }; + delete newState.sets[setId]; + this.setState(newState); + }); + } + + showEditUserRole = (role, index) => { + let user; + if (role === "owner") { + user = this.state.groupUsers.owners[index]; + } else if (role === "contributor") { + user = this.state.groupUsers.contributors[index]; + } else { + user = this.state.groupUsers.members[index]; + } + this.setState({ + editingUser: { + uid: user.uid, + role: role, + index: index, + }, + }); + } + + hideEditUserRole = () => { + this.setState({ + editingUser: null, + }); + } + + editUserRole = (role) => { + if (role === this.state.editingUser.role) { + this.setState({ + editingUser: null, + }); + } else { + if (role === "remove") { + this.state.db.collection("users") + .doc(this.state.editingUser.uid) + .collection("groups") + .doc(this.props.match.params.groupId) + .delete() + .then(() => { + let groupUsers = this.state.groupUsers; + if (this.state.editingUser.role === "owner") { + groupUsers.owners.splice(this.state.editingUser.index, 1); + } else if (this.state.editingUser.role === "contributor") { + groupUsers.contributors.splice(this.state.editingUser.index, 1); + } else { + groupUsers.members.splice(this.state.editingUser.index, 1); + } + this.setState({ + editingUser: null, + groupUsers: groupUsers, + }); + }).catch((error) => { + this.setState({ + editingUser: null, + }); + console.log(`Couldn't change user role: ${error}`) + }); + } else { + this.state.db.collection("users") + .doc(this.state.editingUser.uid) + .collection("groups") + .doc(this.props.match.params.groupId) + .update({ + role: role, + }).then(() => { + let groupUsers = this.state.groupUsers; + let userData; + if (this.state.editingUser.role === "owner") { + userData = groupUsers.owners.splice(this.state.editingUser.index, 1)[0]; + } else if (this.state.editingUser.role === "contributor") { + userData = groupUsers.contributors.splice(this.state.editingUser.index, 1)[0]; + } else { + userData = groupUsers.members.splice(this.state.editingUser.index, 1)[0]; + } + if (role === "owner") { + groupUsers.owners.push(userData); + } else if (role === "contributor") { + groupUsers.contributors.push(userData); + } else { + groupUsers.members.push(userData); + } + this.setState({ + editingUser: null, + groupUsers: groupUsers, + }); + }).catch((error) => { + this.setState({ + editingUser: null, + }); + console.log(`Couldn't change user role: ${error}`) + }); + } + } + } + + showDeleteGroup = () => { + this.setState({ + showDeleteGroup: true, + }); + } + + hideDeleteGroup = () => { + this.setState({ + showDeleteGroup: false, + }); + } + + deleteGroup = () => { + this.setState({ + deleteGroupLoading: true, + }); + + this.state.db.collection("groups") + .doc(this.props.match.params.groupId) + .delete() + .then(() => { + this.props.history.push("/groups"); + }).catch((error) => { + console.log(`Couldn't delete group: ${error}`); + this.setState({ + deleteGroupLoading: false, + }); + }) + } + + render() { + return ( +
+ +
+ { + (this.state.role === null) + ? + Loading... + : + <> +
+ { + this.state.editGroupName && this.state.role === "owner" + ? +

+ (this.groupNameInput = inputEl)} + /> + +

+ : +

{}}> + {this.state.groupName} + { + this.state.role === "owner" && + + + + } +

+ } + { + this.state.role === "owner" && + + } +
+ { + this.state.joinCode && +
+

Join code

+

{this.state.joinCode}

+
+ } + { + this.state.memberCount && +
+

{this.state.memberCount}

+

+ member + { this.state.memberCount !== 1 && "s" } +

+
+ } + +
+

Sets

+ { + Object.keys(this.state.sets).length > 0 + ? +
+ { + Object.keys(this.state.sets).map((setId) => +
+ + {this.state.sets[setId].displayName} + + { + this.state.role === "owner" && + + } +
+ ) + } +
+ : +

+ This group doesn't have any sets yet! +

+ } +
+ { + this.state.role === "owner" && + <> +
+

Members

+ { + this.state.groupUsers && this.state.groupUsers.owners.length > 0 && + <> +

Owners

+
+ { + this.state.groupUsers.owners.map((user, index) => +

{ } : () => this.showEditUserRole("owner", index)} + > + { + user.uid === this.state.user.uid + ? + "You" + : + <> + {user.displayName} + + + } +

+ ) + } +
+ + } + { + this.state.groupUsers && this.state.groupUsers.contributors.length > 0 && + <> +

Contributors

+
+ { + this.state.groupUsers.contributors.map((user, index) => +

{ } : () => this.showEditUserRole("contributor", index)} + > + { + user.uid === this.state.user.uid + ? + "You" + : + <> + {user.displayName} + + + } +

+ ) + } +
+ + } + { + this.state.groupUsers && this.state.groupUsers.members.length > 0 && + <> +

Members

+
+ { + this.state.groupUsers.members.map((user, index) => +

{ } : () => this.showEditUserRole("member", index)} + > + { + user.uid === this.state.user.uid + ? + "You" + : + <> + {user.displayName} + + + } +

+ ) + } +
+ + } +
+ { + this.state.editingUser && + <> +
+
+ { + ["Owner", "Contributor", "Member", "Remove"].map((role) => +

this.editUserRole(role.toLowerCase())} + > + {role} +

+ ) + } + +
+ Cancel +
+
+ + } + { + this.state.showDeleteGroup && + <> +
+
+

Are you sure you want to delete this group?

+
+ + +
+
+ + } + + } + + } +
+
+
+ ) + } +}) diff --git a/src/History.js b/src/History.js new file mode 100644 index 0000000..bc8cc57 --- /dev/null +++ b/src/History.js @@ -0,0 +1,199 @@ +import React, { Component } from 'react'; +import { HomeRounded as HomeRoundedIcon, QuestionAnswerRounded as QuestionAnswerRoundedIcon, PeopleRounded as PeopleRoundedIcon, SwapHorizRounded as SwapHorizRoundedIcon, DeleteRounded as DeleteRoundedIcon } from "@material-ui/icons"; +import NavBar from "./NavBar"; +import Button from "./Button"; +import Footer from "./Footer"; +import { Link } from 'react-router-dom'; +import "./css/History.css"; + +export default class History extends Component { + constructor(props) { + super(props); + this.state = { + user: props.user, + db: props.db, + navbarItems: [ + { + type: "link", + link: "/", + icon: , + hideTextMobile: true, + } + ], + progressHistoryComplete: [], + progressHistoryIncomplete: [], + }; + + let isMounted = true; + Object.defineProperty(this, "isMounted", { + get: () => isMounted, + set: (value) => isMounted = value, + }); + } + + setState = (state, callback = null) => { + if (this.isMounted) super.setState(state, callback); + } + + componentDidMount() { + document.title = "History | Parandum"; + + this.state.db.collection("progress") + .where("uid", "==", this.state.user.uid) + .orderBy("start_time", "desc") + .get() + .then((querySnapshot) => { + let complete = []; + let incomplete = []; + + querySnapshot.docs.map((doc) => { + const data = doc.data(); + + if (data.duration !== null) { + return complete.push({ + id: doc.id, + setTitle: data.set_title, + switchLanguage: data.switch_language, + progress: (data.progress / data.questions.length * 100).toFixed(2), + mark: (data.progress > 0 ? data.correct.length / data.progress * 100 : 0).toFixed(2), + mode: data.mode, + }); + } else { + return incomplete.push({ + id: doc.id, + setTitle: data.set_title, + switchLanguage: data.switch_language, + progress: (data.progress / data.questions.length * 100).toFixed(2), + mark: (data.progress > 0 ? data.correct.length / data.progress * 100 : 0).toFixed(2), + mode: data.mode, + }); + } + }); + + this.setState({ + progressHistoryComplete: complete, + progressHistoryIncomplete: incomplete, + }); + }).catch((error) => { + console.log(`Couldn't retrieve progress history: ${error}`); + }); + } + + componentWillUnmount() { + this.isMounted = false; + } + + deleteProgress = (progressId) => { + this.state.db.collection("progress") + .doc(progressId) + .delete() + .then(() => { + const progressIndex = this.state.progressHistoryIncomplete.map((obj) => obj.id).indexOf(progressId); + let newState = { + progressHistoryIncomplete: this.state.progressHistoryIncomplete, + }; + delete newState.progressHistoryIncomplete[progressIndex]; + this.setState(newState); + }); + } + + render() { + return ( +
+ +
+

History

+ + { + this.state.progressHistoryComplete.length > 0 || this.state.progressHistoryIncomplete.length > 0 + ? + <> + { + this.state.progressHistoryIncomplete.length > 0 && +
+

Incomplete

+
+

Set

+

Progress

+

Mark

+

Mode

+
+ { + this.state.progressHistoryIncomplete.map((progressItem) => +
+ + {progressItem.setTitle} + { + progressItem.switchLanguage && + + } + +

{progressItem.progress}%

+

{progressItem.mark}%

+

+ { + progressItem.mode === "questions" + ? + + : + + } +

+ +
+ ) + } +
+ } + { + this.state.progressHistoryComplete.length > 0 && +
+

Completed

+
+

Set

+

Progress

+

Mark

+

Mode

+
+ { + this.state.progressHistoryComplete.map((progressItem) => +
+ + {progressItem.setTitle} + { + progressItem.switchLanguage && + + } + +

{progressItem.progress}%

+

{progressItem.mark}%

+ { + progressItem.mode === "questions" + ? + + : + + } +
+ ) + } +
+ } + + : +

You haven't done any tests yet.

+ } +
+
+
+ ) + } +} diff --git a/src/Home.js b/src/Home.js new file mode 100644 index 0000000..9af2487 --- /dev/null +++ b/src/Home.js @@ -0,0 +1,34 @@ +import React from 'react'; +import "@material-ui/core"; +import { AccountCircleRounded as AccountCircleIcon } from "@material-ui/icons"; +import "./css/Home.css"; +import NavBar from './NavBar'; +import Footer from "./Footer"; + +export default function Home() { + const navbarItems = [ + { + type: "link", + name: "Login", + link: "/login", + icon: , + hideTextMobile: false, + } + ]; + + document.title = "Parandum"; + + return ( +
+ + +
+
+

Parandum

+

The next-generation ad-free language-learning platform

+
+
+
+
+ ) +} diff --git a/src/LoggedInHome.js b/src/LoggedInHome.js new file mode 100644 index 0000000..bfb38f7 --- /dev/null +++ b/src/LoggedInHome.js @@ -0,0 +1,434 @@ +import React from 'react'; +import NavBar from "./NavBar"; +import { Link } from "react-router-dom"; +import { ExitToAppRounded as ExitToAppRoundedIcon, HistoryRounded as HistoryRoundedIcon, SettingsRounded as SettingsRoundedIcon, PersonRounded as PersonRoundedIcon, PublicRounded as PublicRoundedIcon, GroupRounded as GroupRoundedIcon, AddRounded as AddRoundedIcon, SwapHorizRounded as SwapHorizRoundedIcon, PeopleRounded as PeopleRoundedIcon, DeleteRounded as DeleteRoundedIcon, QuestionAnswerRounded as QuestionAnswerRoundedIcon } from "@material-ui/icons"; +import Checkbox from '@material-ui/core/Checkbox'; + +import Xarrow from 'react-xarrows'; + +import "firebase/auth"; +import "firebase/firestore"; +import "firebase/functions"; +import Button from './Button'; +import LinkButton from './LinkButton'; +import Footer from "./Footer"; +import "./css/Form.css"; +import "./css/History.css"; +import "./css/LoggedInHome.css"; + +import { withRouter } from "react-router-dom"; + +export default withRouter(class LoggedInHome extends React.Component { + constructor(props) { + super(props); + this.state = { + user: props.user, + db: props.db, + functions: { + createProgress: props.functions.httpsCallable("createProgress"), + }, + canStartTest: false, + loading: false, + selections: {}, + navbarItems: [ + { + type: "link", + name: "History", + link: "/history", + icon: , + hideTextMobile: true, + }, + { + type: "link", + name: "Settings", + link: "/settings", + icon: , + hideTextMobile: true, + }, + { + type: "button", + name: "Logout", + onClick: () => this.state.firebase.auth().signOut(), + icon: , + hideTextMobile: true, + } + ], + progressHistoryIncomplete: [], + }; + + let isMounted = true; + Object.defineProperty(this, "isMounted", { + get: () => isMounted, + set: (value) => isMounted = value, + }); + } + + setState = (state, callback = null) => { + if (this.isMounted) super.setState(state, callback); + } + + componentDidMount() { + document.title = "Parandum"; + + const userSetsRef = this.state.db.collection("sets") + .where("owner", "==", this.state.user.uid) + .orderBy("title"); + const publicSetsRef = this.state.db.collection("sets") + .where("public", "==", true) + .where("owner", "!=", this.state.user.uid) + .orderBy("owner") + .orderBy("title"); + const userGroupsRef = this.state.db.collection("users") + .doc(this.state.user.uid) + .collection("groups"); + const userGroupSetsRef = this.state.db.collection("sets"); + const groupRef = this.state.db.collection("groups"); + const progressRef = this.state.db.collection("progress") + .where("uid", "==", this.state.user.uid) + .where("duration", "==", null) + .orderBy("start_time", "desc") + + let newState = this.state; + + const userSetsQuery = userSetsRef.get().then((userSetsQuerySnapshot) => { + newState.userSets = userSetsQuerySnapshot.docs; + + userSetsQuerySnapshot.docs.map((doc) => newState.selections[doc.id] = false); + + }); + const publicSetsQuery = publicSetsRef.get().then((publicSetsQuerySnapshot) => { + newState.publicSets = publicSetsQuerySnapshot.docs; + + publicSetsQuerySnapshot.docs.map((doc) => newState.selections[doc.id] = false); + + }); + const userGroupsQuery = userGroupsRef.get().then(async (userGroupsQuerySnapshot) => { + newState.user.groups = []; + var userGroupSets = []; + + return Promise.all(userGroupsQuerySnapshot.docs.map((group) => { + newState.user.groups.push(group.id); + + const groupData = groupRef.doc(group.id).get(); + + return userGroupSetsRef + .where("public", "==", true) + .where("groups", "array-contains", group.id) + .get().then(async (userGroupSetsQuerySnapshot) => { + groupData.then((result) => { + if (typeof result !== "undefined" && typeof result.data === "function" && userGroupSetsQuerySnapshot.docs.length > 0) { + userGroupSets.push({ + group: result, + sets: userGroupSetsQuerySnapshot.docs, + }); + + userGroupSetsQuerySnapshot.docs.map((doc) => newState.selections[doc.id] = false); + } + }); + }); + })).then(() => { + newState.userGroupSets = userGroupSets.sort((a,b) => { + if (a.group.data().display_name < b.group.data().display_name) { + return -1; + } + if (a.group.data().display_name > b.group.data().display_name) { + return 1; + } + return 0; + }) + }); + }); + const progressQuery = progressRef.get().then((progressQuerySnapshot) => { + progressQuerySnapshot.docs.map((doc) => { + const data = doc.data(); + return newState.progressHistoryIncomplete.push({ + id: doc.id, + setTitle: data.set_title, + switchLanguage: data.switch_language, + progress: (data.progress / data.questions.length * 100).toFixed(2), + mark: (data.progress > 0 ? data.correct.length / data.progress * 100 : 0).toFixed(2), + mode: data.mode, + }); + }) + }) + + Promise.all([ + userSetsQuery, + publicSetsQuery, + userGroupsQuery, + progressQuery + ]).then(() => { + this.setState(newState); + }); + } + + componentWillUnmount() { + this.isMounted = false; + } + + stopLoading = () => { + this.setState({ + canStartTest: true, + loading: false, + }); + } + + startTest = () => { + if (this.state.canStartTest) { + const selections = Object.keys(this.state.selections) + .filter(x => this.state.selections[x]); + this.state.functions.createProgress({ + sets: selections, + switch_language: false, + mode: "questions", + limit: 10, + }).then((result) => { + const progressId = result.data; + this.stopLoading(); + this.props.history.push("/progress/" + progressId); + }).catch((error) => { + console.log(`Couldn't start test: ${error}`); + this.stopLoading(); + }); + + this.setState({ + canStartTest: false, + loading: true, + }); + } + } + + handleSetSelectionChange = (event) => { + let newState = { ...this.state }; + newState.selections[event.target.name] = event.target.checked; + this.setState(newState); + if (Object.values(this.state.selections).indexOf(true) > -1) { + this.setState({ canStartTest: true }); + } else { + this.setState({ canStartTest: false }); + } + } + + deleteProgress = (progressId) => { + this.state.db.collection("progress") + .doc(progressId) + .delete() + .then(() => { + const progressIndex = this.state.progressHistoryIncomplete.map((obj) => obj.id).indexOf(progressId); + let newState = { + progressHistoryIncomplete: this.state.progressHistoryIncomplete, + }; + newState.progressHistoryIncomplete.splice(progressIndex); + this.setState(newState); + }); + } + + render() { + return ( +
+ + +
+
+

Study

+
+ + + Groups + + { + this.state.userSets && this.state.userSets.length > 0 && + + My Sets + + } + } className="button--round"> +
+ } className="button--round buttons--mobile"> +
+ { + this.state.progressHistoryIncomplete.length > 0 && + <> +

Incomplete Tests

+
+
+

Set

+

Progress

+

Mark

+

Mode

+
+ { + this.state.progressHistoryIncomplete.map((progressItem) => +
+ + {progressItem.setTitle} + { + progressItem.switchLanguage && + + } + +

{progressItem.progress}%

+

{progressItem.mark}%

+

+ { + progressItem.mode === "questions" + ? + + : + + } +

+ +
+ ) + } +
+ + } +
+ + + Groups + + { + this.state.userSets && this.state.userSets.length > 0 && + + My Sets + + } +
+

+ { + this.state.userSets && this.state.userSets.length > 0 + ? + "Choose sets to study" + : + "Create a set to start studying!" + } +

+ { + (this.state.userSets && this.state.userSets.length === 0) && (this.state.userGroupSets && this.state.userGroupSets.length === 0) && this.state.progressHistoryIncomplete.length === 0 && + <> + + + } +
+ {this.state.userSets && this.state.userSets.length > 0 && +
+

Personal Sets

+
+ {this.state.userSets + .sort((a, b) => { + if (a.data().title < b.data().title) { + return -1; + } + if (a.data().title > b.data().title) { + return 1; + } + return 0; + }) + .map(set => +
+ +
+ )} +
+
+ } + {this.state.userGroupSets && this.state.userGroupSets.length > 0 && this.state.userGroupSets.map(data => + data.sets && data.sets.length > 0 && +
+ +

{data.group.data().display_name}

+ + +
+ {data.sets.map(set => +
+ +
+ )} +
+
+ )} + {this.state.publicSets && this.state.publicSets.length > 0 && +
+

Public Sets

+
+ {this.state.publicSets.map(set => +
+ +
+ )} +
+
+ } +
+
+
+
+ ) + } +}) \ No newline at end of file diff --git a/src/Login.js b/src/Login.js new file mode 100644 index 0000000..15b040a --- /dev/null +++ b/src/Login.js @@ -0,0 +1,44 @@ +import React from 'react'; +import Home from './Home'; +import StyledFirebaseAuth from 'react-firebaseui/StyledFirebaseAuth'; +import "./css/Login.css"; +import "./css/PopUp.css"; +import { Link } from 'react-router-dom'; +import "@material-ui/core"; +import { CloseRounded as CloseRoundedIcon } from "@material-ui/icons"; + +import "firebase/auth"; + +export default function Login(props) { + const auth = props.auth; + const uiConfig = { + signInFlow: 'redirect', + signInSuccessUrl: '/', + signInOptions: [ + props.firebase.auth.GoogleAuthProvider.PROVIDER_ID, + "microsoft.com", + props.firebase.auth.EmailAuthProvider.PROVIDER_ID + ], + credentialHelper: props.firebase.auth.CredentialHelper.GOOGLE_YOLO, + callbacks: { + signInSuccessWithAuthResult: () => false, + }, + }; + + document.body.style.overflow = "hidden"; + document.title = "Login | Parandum"; + + return ( + <> + + +
+

Login

+ + + + +
+ + ) +} diff --git a/src/Progress.js b/src/Progress.js new file mode 100644 index 0000000..68e9420 --- /dev/null +++ b/src/Progress.js @@ -0,0 +1,622 @@ +import React from 'react'; +import { withRouter } from "react-router-dom"; +import { HomeRounded as HomeRoundedIcon, ArrowForwardRounded as ArrowForwardRoundedIcon, SettingsRounded as SettingsRoundedIcon, CloseRounded as CloseRoundedIcon } from "@material-ui/icons"; +import NavBar from "./NavBar"; +import Button from "./Button"; +import LinkButton from "./LinkButton"; +import Error404 from "./Error404"; +import SettingsContent from "./SettingsContent"; +import Footer from "./Footer"; + +import "./css/PopUp.css"; +import "./css/Progress.css"; + +export default withRouter(class Progress extends React.Component { + constructor(props) { + super(props); + this.state = { + user: props.user, + db: props.db, + functions: { + processAnswer: props.functions.httpsCallable("processAnswer"), + }, + loading: false, + canProceed: true, + navbarItems: [ + { + type: "button", + onClick: this.showSettings, + icon: , + hideTextMobile: true, + }, + { + type: "link", + link: "/", + icon: , + hideTextMobile: true, + } + ], + progressInaccessible: false, + correct: 0, + incorrect: 0, + totalQuestions: 0, + progress: 0, + setTitle: "", + switchLanguage: false, + answerInput: "", + currentPrompt: "", + currentSound: false, + currentSetOwner: "", + nextPrompt: "", + nextSound: false, + nextSetOwner: "", + currentAnswerStatus: null, + currentCorrect: [], + moreAnswers: true, + duration: 0, + incorrectAnswers: {}, + showSettings: false, + soundInput: this.props.sound, + themeInput: this.props.theme, + setIds: [], + attemptNumber: 1, + }; + + let isMounted = true; + Object.defineProperty(this, "isMounted", { + get: () => isMounted, + set: (value) => isMounted = value, + }); + } + + setState = (state, callback=null) => { + if (this.isMounted) super.setState(state, callback); + } + + async componentDidMount() { + const progressId = this.props.match.params.progressId; + const progressRef = this.state.db.collection("progress").doc(progressId); + + let [ newState, setDone, incorrectAnswers, duration ] = await progressRef.get().then((doc) => { + const data = doc.data(); + + document.title = `Study | ${data.set_title} | Parandum`; + + let newState = { + correct: data.correct.length, + incorrect: data.incorrect.length, + totalQuestions: data.questions.length, + questions: data.questions, + progress: data.progress, + setTitle: data.set_title, + switchLanguage: data.switch_language, + currentSetOwner: data.set_owner, + currentCorrect: data.current_correct, + mode: data.mode, + nextPrompt: null, + setIds: data.setIds, + }; + + if (data.lives) { + newState.lives = data.lives; + } + + return [ newState, data.duration !== null, data.incorrect, data.duration ]; + }).catch((error) => { + console.log(`Progress data inaccessible: ${error}`); + return [ + { + progressInaccessible: true, + }, + true + ]; + }); + + if (!newState.progressInaccessible && !setDone) { + let nextPromptRef; + if (!newState.switchLanguage) { + nextPromptRef = progressRef + .collection("terms") + .doc(newState.questions[newState.progress]); + } else { + nextPromptRef = progressRef + .collection("definitions") + .doc(newState.questions[newState.progress]); + } + + await nextPromptRef.get().then((doc) => { + newState.currentPrompt = doc.data().item; + newState.currentSound = doc.data().sound === true; + }).catch((error) => { + newState.progressInaccessible = true; + console.log(`Progress data inaccessible: ${error}`); + }); + } else if (setDone) { + newState.moreAnswers = false; + newState.currentAnswerStatus = true; + newState.duration = duration; + + let promises = []; + + promises.push(this.state.db.collection("progress") + .where("uid", "==", this.state.user.uid) + .where("setIds", "==", newState.setIds) + .orderBy("start_time") + .get() + .then((querySnapshot) => { + newState.attemptNumber = querySnapshot.docs.map((doc) => doc.id).indexOf(this.props.match.params.progressId) + 1; + })); + + if (incorrectAnswers.length > 0) { + newState.incorrectAnswers = {}; + + promises.push(Promise.all(incorrectAnswers.map((vocabId) => { + if (newState.incorrectAnswers[vocabId]) { + return newState.incorrectAnswers[vocabId].count++; + } else { + newState.incorrectAnswers[vocabId] = { + count: 1, + }; + + return Promise.all([ + progressRef.collection("terms") + .doc(vocabId) + .get().then((termDoc) => { + newState.switchLanguage ? newState.incorrectAnswers[vocabId].answer = termDoc.data().item.split("/") : newState.incorrectAnswers[vocabId].prompt = termDoc.data().item; + }), + progressRef.collection("definitions") + .doc(vocabId) + .get().then((definitionDoc) => { + newState.switchLanguage ? newState.incorrectAnswers[vocabId].prompt = definitionDoc.data().item : newState.incorrectAnswers[vocabId].answer = definitionDoc.data().item.split("/"); + }) + ]); + } + })).catch((error) => { + console.log(`Couldn't retrieve incorrect answers: ${error}`); + })); + } + + await Promise.all(promises); + } + + this.setState(newState, () => { + if (!setDone) this.answerInput.focus() + }); + } + + componentWillUnmount() { + this.isMounted = false; + } + + showSettings = () => { + this.setState({ + showSettings: true, + }); + } + + handleSoundInputChange = (event) => { + this.setState({ + soundInput: event.target.checked, + }); + } + + handleThemeInputChange = (newTheme) => { + if (this.state.themeInput !== newTheme) this.setState({ + themeInput: newTheme, + }); + } + + saveSettings = (globalChange) => { + this.props.handleSoundChange(this.state.soundInput, globalChange); + this.props.handleThemeChange(this.state.themeInput, globalChange); + this.hideSettings(); + } + + hideSettings = () => { + this.setState({ + showSettings: false, + }); + } + + handleAnswerInput = (event) => { + this.setState({ + answerInput: event.target.value, + }); + } + + showNextItem = () => { + if (this.state.canProceed) { + if (this.state.currentAnswerStatus === null) { + this.processAnswer(); + } else { + this.nextQuestion(); + } + } + } + + startLoading = () => { + this.setState({ + loading: true, + canProceed: false, + }); + } + + cleanseVocabString = (item) => { + const chars = " .,()-_'\""; + + let newString = item; + + chars.split("").forEach((char) => { + newString = newString.replace(char, ""); + }); + + return newString; + } + + processAnswer = async () => { + if (this.state.canProceed) { + const cleansedCurrentCorrect = this.state.currentCorrect.map(item => this.cleanseVocabString(item)); + + this.startLoading(); + + if (!cleansedCurrentCorrect.includes(this.cleanseVocabString(this.state.answerInput))) { + this.state.functions.processAnswer({ + answer: this.state.answerInput, + progressId: this.props.match.params.progressId, + }).then(async (result) => { + const data = result.data; + let newState = { + currentAnswerStatus: data.correct, + currentCorrect: data.correctAnswers, + moreAnswers: data.moreAnswers, + nextPrompt: data.nextPrompt ? data.nextPrompt.item : null, + nextSound: data.nextPrompt ? data.nextPrompt.sound : null, + nextSetOwner: data.nextPrompt ? data.nextPrompt.set_owner : null, + progress: data.progress, + totalQuestions: data.totalQuestions, + correct: data.totalCorrect, + incorrect: data.totalIncorrect, + currentVocabId: data.currentVocabId, + loading: false, + canProceed: true, + }; + + if (data.correct && !data.moreAnswers && this.state.incorrectAnswers[data.currentVocabId]) { + // all answers to question given correctly + // answer was previously wrong + // store correct answer + newState.incorrectAnswers = this.state.incorrectAnswers; + newState.incorrectAnswers[data.currentVocabId].answer = data.correctAnswers; + } else if (!data.correct) { + // incorrect answer given + // store prompt and count=0 + newState.incorrectAnswers = this.state.incorrectAnswers; + newState.incorrectAnswers[data.currentVocabId] = { + prompt: this.state.currentPrompt, + answer: "", + count: 0, + }; + } + + let promises = []; + + if (data.duration) { + // test done + newState.duration = data.duration; + + promises.push(this.state.db.collection("progress") + .where("uid", "==", this.state.user.uid) + .where("setIds", "==", this.state.setIds) + .orderBy("start_time") + .get() + .then((querySnapshot) => { + console.log(querySnapshot); + newState.attemptNumber = querySnapshot.docs.map((doc) => doc.id).indexOf(this.props.match.params.progressId) + 1; + })); + } + + if (data.incorrectAnswers) { + let unsavedAnswers = {}; + + if (!newState.incorrectAnswers) { + newState.incorrectAnswers = {}; + } + + data.incorrectAnswers.map((vocabId) => { + if (newState.incorrectAnswers[vocabId]) { + // already been logged including prompt and correct answer + newState.incorrectAnswers[vocabId].count++; + } else { + // not been saved yet + // update count in unsaved answers + unsavedAnswers[vocabId] ? unsavedAnswers[vocabId]++ : unsavedAnswers[vocabId] = 1; + } + return true; + }); + + promises.push(Promise.all(Object.keys(unsavedAnswers).map((vocabId) => { + // get and store vocab docs that haven't already been stored (due to refresh) + const progressDocRef = this.state.db + .collection("progress") + .doc(this.props.match.params.progressId); + + newState.incorrectAnswers[vocabId] = { + count: unsavedAnswers[vocabId], + }; + + return Promise.all([ + progressDocRef.collection("terms") + .doc(vocabId) + .get().then((termDoc) => { + this.state.switchLanguage ? newState.incorrectAnswers[vocabId].answer = termDoc.data().item.split("/") : newState.incorrectAnswers[vocabId].prompt = termDoc.data().item; + }), + progressDocRef.collection("definitions") + .doc(vocabId) + .get().then((definitionDoc) => { + this.state.switchLanguage ? newState.incorrectAnswers[vocabId].prompt = definitionDoc.data().item : newState.incorrectAnswers[vocabId].answer = definitionDoc.data().item.split("/"); + }) + ]); + }))); + } + + await Promise.all(promises); + + this.setState(newState); + }).catch((error) => { + console.log(`Couldn't process answer: ${error}`); + this.setState({ + loading: false, + canProceed: true, + }); + }); + } else { + this.setState({ + currentAnswerStatus: null, + answerInput: "", + loading: false, + canProceed: true, + }); + } + } + } + + nextQuestion = () => { + if (this.state.canProceed) { + this.startLoading(); + + let newState = { + currentAnswerStatus: null, + answerInput: "", + loading: false, + canProceed: true, + }; + + if (!this.state.moreAnswers) { + newState.currentCorrect = []; + newState.currentPrompt = this.state.nextPrompt; + newState.currentSound = this.state.nextSound; + newState.currentSetOwner = this.state.nextSetOwner; + } + + this.setState(newState, () => (this.isMounted) && this.answerInput.focus()); + } + } + + msToTime = (time) => { + const localeData = { + minimumIntegerDigits: 2, + useGrouping: false, + }; + const seconds = Math.floor((time / 1000) % 60).toLocaleString("en-GB", localeData); + const minutes = Math.floor((time / 1000 / 60) % 60).toLocaleString("en-GB", localeData); + const hours = Math.floor(time / 1000 / 60 / 60).toLocaleString("en-GB", localeData); + + return `${hours}:${minutes}:${seconds}`; + } + + render() { + return ( +
+ { + this.state.progressInaccessible + ? + + : + <> + +
+ { + this.state.currentAnswerStatus === null + ? + <> +

{this.state.currentPrompt}

+
e.preventDefault()} > + + (this.answerInput = inputEl)} + /> + +
+
+ { + this.state.currentCorrect && this.state.currentCorrect.length > 0 + ? + <> +

+ { + this.state.moreAnswers + ? + "Correct so far:" + : + "Answers:" + } +

+ {this.state.currentCorrect.map((vocab, index) => +

{vocab}

+ )} + + : + "" + } +
+ + : + this.state.nextPrompt === null && !this.state.moreAnswers + ? + <> + {/* DONE */} +

{this.state.setTitle}

+
+

You got

+

{`${(this.state.correct / this.state.totalQuestions * 100).toFixed(2)}%`}

+
+
+

{`${this.state.correct} of ${this.state.totalQuestions}`}

+

marks

+
+
+

You took

+

{this.msToTime(this.state.duration)}

+
+
+

Attempt #

+

{this.state.attemptNumber}

+
+ { + this.state.incorrectAnswers && Object.keys(this.state.incorrectAnswers).length > 0 && + <> +

Incorrect answers:

+
+
+

Prompt

+

Answer

+

Mistakes

+
+ { + Object.keys(this.state.incorrectAnswers).map(key => + [key, this.state.incorrectAnswers[key].count]) + .sort((a,b) => b[1] - a[1]).map(item => +
+

{this.state.incorrectAnswers[item[0]].prompt ? this.state.incorrectAnswers[item[0]].prompt : ""}

+

{this.state.incorrectAnswers[item[0]].answer ? this.state.incorrectAnswers[item[0]].answer.join("/") : ""}

+

{this.state.incorrectAnswers[item[0]].count}

+
+ ) + } +
+ + } + {/* TODO: provide the attempt number -- .get() where array-contains-all array of setIds from original sets? would mean a new field in db and adjusting cloud fns again */} + +
+ + Done + +
+ + : + <> + {/* ANSWER PROCESSED */} +

{this.state.currentPrompt}

+
e.preventDefault()} > + + + +
+
+ { + this.state.currentCorrect + ? + <> +

+ { + this.state.moreAnswers + ? + "Correct so far:" + : + "Answers:" + } +

+ {this.state.currentCorrect.map((vocab, index) => +

{vocab}

+ )} + + : + "" + } +
+ + } +
+
+ + { + this.state.showSettings && + <> +
+
+ + + +
+ + +
+ + +
+ + } + + } +
+ ) + } +}) diff --git a/src/SetPage.js b/src/SetPage.js new file mode 100644 index 0000000..9bbf5be --- /dev/null +++ b/src/SetPage.js @@ -0,0 +1,370 @@ +import React from 'react'; +import { withRouter } from "react-router-dom"; +import { HomeRounded as HomeRoundedIcon, PlayArrowRounded as PlayArrowRoundedIcon, EditRounded as EditRoundedIcon, CloudQueueRounded as CloudQueueRoundedIcon, GroupAddRounded as GroupAddRoundedIcon, CloseRounded as CloseRoundedIcon, DeleteRounded as DeleteRoundedIcon } from "@material-ui/icons"; +import NavBar from "./NavBar"; +import Button from "./Button"; +import LinkButton from "./LinkButton"; +import Error404 from "./Error404"; +import Footer from "./Footer"; + +import "./css/PopUp.css"; +import "./css/SetPage.css"; +import "./css/ConfirmationDialog.css"; + +export default withRouter(class SetPage extends React.Component { + constructor(props) { + super(props); + this.state = { + user: props.user, + db: props.db, + functions: { + createProgress: props.functions.httpsCallable("createProgress"), + addSetToGroup: props.functions.httpsCallable("addSetToGroup"), + }, + loading: false, + canStartTest: true, + navbarItems: [ + { + type: "link", + link: "/", + icon: , + hideTextMobile: true, + } + ], + set: { + title: "", + public: false, + vocab: [], + }, + setInaccessible: false, + showAddSetToGroup: false, + canAddSetToGroup: true, + addSetToGroupLoading: {}, + groups: {}, + currentSetGroups: [], + showDeleteConfirmation: false, + }; + + let isMounted = true; + Object.defineProperty(this, "isMounted", { + get: () => isMounted, + set: (value) => isMounted = value, + }); + } + + setState = (state, callback = null) => { + if (this.isMounted) super.setState(state, callback); + } + + componentDidMount() { + const setId = this.props.match.params.setId; + const setRef = this.state.db + .collection("sets") + .doc(setId); + const setVocabRef = setRef + .collection("vocab") + .orderBy("term"); + + setRef.get().then((setDoc) => { + document.title = `${setDoc.data().title} | Parandum`; + + setVocabRef.get().then((querySnapshot) => { + let vocab = []; + querySnapshot.docs.map((doc) => { + const data = doc.data(); + return vocab.push({ + term: data.term, + definition: data.definition, + sound: data.sound, + }); + }); + this.setState({ + set: { + ...this.state.set, + title: setDoc.data().title, + public: setDoc.data().public, + vocab: vocab, + owner: setDoc.data().owner, + }, + currentSetGroups: setDoc.data().groups, + }); + }); + }).catch((error) => { + this.setState({ + setInaccessible: true, + }); + console.log(`Can't access set: ${error}`); + }); + } + + componentWillUnmount() { + this.isMounted = false; + } + + stopLoading = () => { + this.setState({ + canStartTest: true, + loading: false, + }); + } + + startTest = () => { + if (this.state.canStartTest) { + this.state.functions.createProgress({ + sets: [this.props.match.params.setId], + switch_language: false, + mode: "questions", + limit: 10, + }).then((result) => { + const progressId = result.data; + this.stopLoading(); + this.props.history.push("/progress/" + progressId); + }).catch((error) => { + console.log(`Couldn't start test: ${error}`); + this.stopLoading(); + }); + + this.setState({ + canStartTest: false, + loading: true, + }); + } + }; + + showAddSetToGroup = async () => { + let newState = { + showAddSetToGroup: true, + groups: {}, + addSetToGroupLoading: {}, + }; + await this.state.db.collection("users") + .doc(this.state.user.uid) + .collection("groups") + .where("role", "!=", "member") + .get() + .then((querySnapshot) => { + return Promise.all(querySnapshot.docs.map((userGroupDoc) => { + if (!this.state.currentSetGroups.includes(userGroupDoc.id)) + return this.state.db.collection("groups") + .doc(userGroupDoc.id) + .get() + .then((groupDoc) => { + newState.groups[userGroupDoc.id] = groupDoc.data().display_name; + newState.addSetToGroupLoading[userGroupDoc.id] = false; + }); + return true; + })); + }) + this.setState(newState); + } + + hideAddSetToGroup = () => { + this.setState({ + showAddSetToGroup: false, + }); + } + + stopAddSetToGroupLoading = (groupId, addedSetToGroup = false) => { + let newState = { + addSetToGroupLoading: { + ...this.state.addSetToGroupLoading, + [groupId]: false, + }, + canAddSetToGroup: true, + showAddSetToGroup: false, + }; + if (addedSetToGroup) newState.currentSetGroups = this.state.currentSetGroups.concat(groupId); + this.setState(newState); + } + + addSetToGroup = (groupId) => { + if (this.state.canAddSetToGroup) { + this.setState({ + addSetToGroupLoading: { + ...this.state.addSetToGroupLoading, + [groupId]: true, + }, + canAddSetToGroup: false, + }); + + this.state.functions.addSetToGroup({ + groupId: groupId, + setId: this.props.match.params.setId, + }).then((result) => { + this.stopAddSetToGroupLoading(groupId, true); + }).catch((error) => { + console.log(`Couldn't add set to group: ${error}`); + }); + } + } + + showDeleteSet = () => { + this.setState({ + showDeleteConfirmation: true, + }); + } + + hideDeleteConfirmation = () => { + this.setState({ + showDeleteConfirmation: false, + }); + } + + deleteSet = () => { + this.state.db.collection("sets") + .doc(this.props.match.params.setId) + .delete() + .then(() => { + this.props.history.push("/"); + }).catch((error) => { + console.log(`Couldn't delete set: ${error}`); + this.setState({ + showDeleteConfirmation: false, + }) + }); + } + + render() { + return ( +
+ { + this.state.setInaccessible + ? + + : + <> + + +
+
+

+ {this.state.set.title} + { + this.state.set.public + ? + + : + "" + } +

+
+ + + { + this.state.set.owner === this.state.user.uid && + } + className="button--round" + > + } + { + this.state.set.owner === this.state.user.uid && this.state.currentSetGroups.length === 0 && + + } +
+
+ +
+
+

Terms

+

Definitions

+
+ + {this.state.set.vocab.map((contents, index) => +
+ {contents.term} + {contents.definition} +
+ )} +
+
+
+ + { + this.state.showAddSetToGroup + ? + <> +
+
+ { + Object.keys(this.state.groups).length < 1 + ? + <> +

No Groups Found

+ This could be because: +
    +
  • you're not a member of any groups
  • +
  • this set is already a part of your groups
  • +
  • you don't have the required permissions
  • +
+

To add sets to a group, you must be an owner or collaborator.

+ + : + <> +

Select a Group

+ +
+ { + Object.keys(this.state.groups).map((groupId) => + + ) + } +
+ + } + + +
+ + : + this.state.showDeleteConfirmation && + <> +
+
+

Are you sure you want to delete this set?

+
+ + +
+
+ + } + + } +
+ ) + } +}) diff --git a/src/Settings.js b/src/Settings.js new file mode 100644 index 0000000..0c12635 --- /dev/null +++ b/src/Settings.js @@ -0,0 +1,97 @@ +import React, { Component } from 'react'; +import { HomeRounded as HomeRoundedIcon } from "@material-ui/icons"; +import NavBar from "./NavBar"; +import Button from "./Button"; +import { withRouter } from "react-router-dom"; +import SettingsContent from "./SettingsContent"; +import Footer from "./Footer"; + +export default withRouter(class Settings extends Component { + constructor(props) { + super(props); + this.state = { + user: props.user, + db: props.db, + navbarItems: [ + { + type: "link", + link: "/", + icon: , + hideTextMobile: true, + } + ], + soundInput: this.props.sound, + themeInput: this.props.theme, + }; + + let isMounted = true; + Object.defineProperty(this, "isMounted", { + get: () => isMounted, + set: (value) => isMounted = value, + }); + } + + setState = (state, callback = null) => { + if (this.isMounted) super.setState(state, callback); + } + + componentDidMount() { + document.title = "Settings | Parandum"; + } + + componentWillUnmount() { + this.isMounted = false; + } + + handleSoundInputChange = (event) => { + this.setState({ + soundInput: event.target.checked, + }); + } + + handleThemeInputChange = (newTheme) => { + if (this.state.themeInput !== newTheme) this.setState({ + themeInput: newTheme, + }); + } + + saveSettings = (globalChange) => { + this.props.handleSoundChange(this.state.soundInput, globalChange); + this.props.handleThemeChange(this.state.themeInput, globalChange); + this.props.history.push("/"); + } + + render() { + return ( +
+ +
+ + +
+ + +
+
+
+
+ ) + } +}) diff --git a/src/UserGroups.js b/src/UserGroups.js new file mode 100644 index 0000000..232f31f --- /dev/null +++ b/src/UserGroups.js @@ -0,0 +1,347 @@ +import React, { Component } from 'react'; +import { HomeRounded as HomeRoundedIcon, GroupAddRounded as GroupAddRoundedIcon, GroupRounded as GroupRoundedIcon, CloseRounded as CloseRoundedIcon, ArrowForwardRounded as ArrowForwardRoundedIcon, PersonRounded as PersonRoundedIcon, EditRounded as EditRoundedIcon, VisibilityRounded as VisibilityRoundedIcon } from "@material-ui/icons"; +import NavBar from "./NavBar"; +import Button from "./Button"; +import Footer from "./Footer"; +import { withRouter, Link } from "react-router-dom"; + +import "./css/PopUp.css"; +import "./css/UserGroups.css"; + +export default withRouter(class UserGroups extends Component { + constructor(props) { + super(props); + this.state = { + user: props.user, + db: props.db, + functions: { + createGroup: props.functions.httpsCallable("createGroup"), + }, + navbarItems: [ + { + type: "link", + link: "/", + icon: , + hideTextMobile: true, + } + ], + createGroup: false, + joinGroup: false, + groupName: "", + joinCode: "", + canCreateGroup: false, + canJoinGroup: false, + canFindGroup: true, + userGroups: null, + }; + + let isMounted = true; + Object.defineProperty(this, "isMounted", { + get: () => isMounted, + set: (value) => isMounted = value, + }); + } + + setState = (state, callback = null) => { + if (this.isMounted) super.setState(state, callback); + } + + componentDidMount() { + document.title = "Groups | Parandum"; + + const userGroupsRef = this.state.db.collection("users") + .doc(this.state.user.uid) + .collection("groups") + .orderBy("role"); + + userGroupsRef.get().then(async (querySnapshot) => { + let newState = { + userGroups: {}, + }; + + await Promise.all(querySnapshot.docs.map((userGroupDoc) => { + return this.state.db + .collection("groups") + .doc(userGroupDoc.id) + .get() + .then((groupDoc) => { + if (userGroupDoc.data() && groupDoc.data()) newState.userGroups[userGroupDoc.id] = { + role: userGroupDoc.data().role, + displayName: groupDoc.data().display_name, + setCount: groupDoc.data().sets.length, + memberCount: Object.keys(groupDoc.data().users).length, + }; + }); + })); + + this.setState(newState); + }); + } + + componentWillUnmount() { + this.isMounted = false; + } + + showJoinGroup = () => { + this.setState({ + joinGroup: true, + }, () => this.joinCodeInput.focus()); + } + + hideJoinGroup = () => { + this.setState({ + joinGroup: false, + }); + } + + showCreateGroup = () => { + this.setState({ + createGroup: true, + }, () => this.groupNameInput.focus()); + } + + hideCreateGroup = () => { + this.setState({ + createGroup: false, + }); + } + + createGroup = () => { + if (this.state.canCreateGroup) { + this.startCreateGroupLoading(); + + this.state.functions.createGroup(this.state.groupName) + .then((result) => { + this.props.history.push(`/groups/${result.data}`); + this.stopCreateGroupLoading(); + }).catch((error) => { + console.log(`Couldn't create group: ${error}`); + this.stopCreateGroupLoading(); + }); + } + } + + joinGroup = () => { + if (this.state.canJoinGroup) { + this.startJoinGroupLoading(); + + this.state.db.collection("join_codes") + .doc(this.state.joinCode) + .get() + .then((joinCodeDoc) => { + this.state.db.collection("users") + .doc(this.state.user.uid) + .collection("groups") + .doc(joinCodeDoc.data().group) + .set({ + role: "member", + }).then(() => { + this.props.history.push(`/groups/${joinCodeDoc.data().group}`); + this.stopJoinGroupLoading(); + }); + }).catch((error) => { + this.stopJoinGroupLoading(false); + }); + } + + } + + handleGroupNameChange = (event) => { + this.setState({ + groupName: event.target.value, + canCreateGroup: event.target.value.replace(" ", "") !== "", + }); + } + + handleJoinCodeChange = (event) => { + this.setState({ + joinCode: event.target.value, + canJoinGroup: event.target.value.replace(" ", "") !== "", + canFindGroup: true, + }); + } + + startJoinGroupLoading = () => { + this.setState({ + loading: true, + canJoinGroup: false, + }); + } + + stopJoinGroupLoading = (canFindGroup = true) => { + let newState = { + loading: false, + canJoinGroup: true, + }; + if (!canFindGroup) newState.canFindGroup = false; + this.setState(newState); + } + + startCreateGroupLoading = () => { + this.setState({ + loading: true, + canCreateGroup: false, + }); + } + + stopCreateGroupLoading = () => { + this.setState({ + loading: false, + canCreateGroup: true, + }); + } + + handleGroupNameInputKeypress = (event) => { + if (event.key === "Enter") { + this.createGroup(); + } + } + + handleJoinCodeInputKeypress = (event) => { + if (event.key === "Enter") { + this.joinGroup(); + } + } + + render() { + return ( +
+ +
+
+

Groups

+
+ + +
+
+ + { + this.state.userGroups && Object.keys(this.state.userGroups).length > 0 + ? + <> +
+ {Object.keys(this.state.userGroups) + .sort((a, b) => { + if (this.state.userGroups[a].displayName < this.state.userGroups[b].displayName) { + return -1; + } + if (this.state.userGroups[a].displayName > this.state.userGroups[b].displayName) { + return 1; + } + return 0; + }) + .map((groupId, index) => + + {this.state.userGroups[groupId].displayName} + + { + this.state.userGroups[groupId].role === "owner" + ? + + : + this.state.userGroups[groupId].role === "contributor" + ? + + : + + } + + + )} +
+ + : +

You aren't a member of any groups

+ } +
+
+ + { + this.state.createGroup + ? + <> +
+
+

Create Group

+ +
+ (this.groupNameInput = inputEl)} + /> + +
+ + +
+ + : + this.state.joinGroup && + <> +
+
+

Join Group

+ +
+ (this.joinCodeInput = inputEl)} + /> + +
+ + { + !this.state.canFindGroup && +

Can't find that group!

+ } + + +
+ + } +
+ ) + } +}) diff --git a/src/UserSets.js b/src/UserSets.js new file mode 100644 index 0000000..a9ebf4e --- /dev/null +++ b/src/UserSets.js @@ -0,0 +1,95 @@ +import React, { Component } from 'react'; +import { withRouter, Link } from 'react-router-dom'; +import { HomeRounded as HomeRoundedIcon, EditRounded as EditRoundedIcon } from "@material-ui/icons"; +import NavBar from "./NavBar"; +import Footer from "./Footer"; + +import "./css/UserSets.css"; + +export default withRouter(class UserSets extends Component { + constructor(props) { + super(props); + this.state = { + user: props.user, + db: props.db, + functions: { + createProgress: props.functions.httpsCallable("createProgress"), + }, + canStartTest: false, + loading: false, + selections: {}, + navbarItems: [ + { + type: "link", + link: "/", + icon: , + hideTextMobile: true, + } + ], + }; + + let isMounted = true; + Object.defineProperty(this, "isMounted", { + get: () => isMounted, + set: (value) => isMounted = value, + }); + } + + setState = (state, callback = null) => { + if (this.isMounted) super.setState(state, callback); + } + + componentDidMount() { + document.title = "Sets | Parandum"; + + const userSetsRef = this.state.db.collection("sets") + .where("owner", "==", this.state.user.uid) + .orderBy("title"); + + userSetsRef.get().then((querySnapshot) => { + this.setState({ + userSets: querySnapshot.docs, + }) + }); + } + + componentWillUnmount() { + this.isMounted = false; + } + + render() { + return ( +
+ + +
+

My Sets

+
+ { + this.state.userSets && this.state.userSets.length > 0 + ? + this.state.userSets + .map((set, index) => +
+ + {set.data().title} + + + + +
+ ) + : +

You haven't made any sets yet!

+ } +
+
+
+ ) + } +})