diff --git a/.editorconfig b/.editorconfig
index 6a85b4ce..a712f8a2 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -10,7 +10,7 @@ trim_trailing_whitespace = true
indent_style = space
indent_size = 2
-[{serve.js,scrape.js,public/js/home.js,public/js/clock.js}]
+[{serve.js,scrape.js,public/js/{home,clock,snackbar}.js}]
indent_style = space
indent_size = 4
diff --git a/public/css/home.css b/public/css/home.css
index 8c8bc344..3b740163 100644
--- a/public/css/home.css
+++ b/public/css/home.css
@@ -52,6 +52,7 @@ body {
--black: #000000;
--transparent-black: rgba(0, 0, 0, 0.6);
+ --transparent-black-lite: rgba(0, 0, 0, 0.2);
--blue: #2196F3;
--blue1: #9ebcea;
@@ -63,6 +64,7 @@ body {
--orange: #f6b93b;
--red: #ff0000;
+ --red1: #a70000;
--schedule1: #63c082;
--schedule2: #72c68e;
@@ -105,6 +107,7 @@ body.dark {
--orange: #f6b93b;
--red: #ff0000;
+ --red1: #ff8585;
--schedule1: #00290e;
--schedule2: #003d14;
@@ -1340,3 +1343,75 @@ hr {
#more-versions a {
font-size: 120%;
}
+
+.snackbar {
+ display: flex;
+ height: 44px;
+ width: auto;
+ max-width: calc(100% - 30px);
+ background-color: var(--gray);
+ position: fixed;
+ left: 0;
+ bottom: 10px;
+ margin: 0px 15px;
+ box-shadow: 2px 2px 5px var(--transparent-black);
+
+ transform: translateY(0px);
+ transition: transform 250ms;
+}
+
+.snackbar.hidden {
+ transform: translateY(54px);
+ transition: transform 250ms;
+}
+
+.snackbar button {
+ height: 32px;
+ margin: 6px 6px 6px 3px;
+ position: relative;
+ border: none;
+ background-color: var(--gray);
+ cursor: pointer;
+ white-space: nowrap;
+ order: 2;
+}
+
+.snackbar button::before {
+ content: '';
+ height: 100%;
+ width: 100%;
+ position: absolute;
+ top: 0;
+ left: 0;
+ background-color:var(--transparent-black-lite);
+ z-index: 1;
+ opacity: 0;
+
+ transition: opacity 100ms ease-in;
+}
+
+.snackbar button:hover::before {
+ opacity: 1;
+}
+
+.snackbar button:focus {
+ outline: none;
+}
+
+.snackbar span {
+ width: auto;
+ vertical-align: middle;
+ line-height: 44px;
+ padding: 0px 8px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ order: 1;
+}
+
+.snackbar button span {
+ line-height: 32px;
+ font-weight: normal;
+ font-size: 16px;
+ font-family: 'Poppins-Regular';
+}
diff --git a/public/home.html b/public/home.html
index 877d6708..a748ade8 100644
--- a/public/home.html
+++ b/public/home.html
@@ -32,6 +32,7 @@
+
diff --git a/public/js/buttonFunctions.js b/public/js/buttonFunctions.js
index ee546259..a682a421 100755
--- a/public/js/buttonFunctions.js
+++ b/public/js/buttonFunctions.js
@@ -1,24 +1,46 @@
let newAssignment = function() {
-
currentTableData.currentTermData.classes[selected_class_i].edited = true;
if (!isNaN(selected_class_i)) {
-
- currentTableData.currentTermData.classes[selected_class_i].assignments.unshift({
- "name": "Assignment",
- "category": Object.keys(currentTableData.currentTermData.classes[selected_class_i].categories)[currentFilterRow >= 0 ? currentFilterRow : 0],
- "score": 10,
- "max_score": 10,
- "percentage": 100,
- "color": "green",
- "synthetic": "true",
- });
+ currentTableData.currentTermData.classes[selected_class_i].assignments
+ .unshift({
+ "assignment_id": newAssignmentIDCounter.toString(),
+ "name": "Assignment",
+ "category": Object.keys(
+ currentTableData.currentTermData.classes[selected_class_i].categories
+ )[currentFilterRow >= 0 ? currentFilterRow : 0],
+ "score": 10,
+ "max_score": 10,
+ "percentage": 100,
+ "color": "green",
+ "synthetic": "true",
+ });
+ newAssignmentIDCounter++;
updateGradePage();
-
}
}
+function replaceAssignmentFromID(oldData, newData, classID) {
+ const assignments = currentTableData.currentTermData.classes[classID]
+ .assignments;
+
+ const index = assignments.findIndex(({ assignment_id }) =>
+ assignment_id === oldData.assignment_id
+ );
+ assignments[index] = newData;
+ updateGradePage();
+}
+
+function removeAssignmentFromID(id, classID) {
+ const assignments = currentTableData.currentTermData.classes[classID]
+ .assignments;
+ const newArray = assignments.filter(({ assignment_id }) =>
+ assignment_id !== id
+ );
+ assignments.length = 0;
+ assignments.push(...newArray);
+}
let editAssignment = function(data) {
@@ -128,7 +150,10 @@ let updateGradePage = function() {
classesTable.replaceData(currentTableData.currentTermData.classes);
categoriesTable.setData(currentTableData.currentTermData.classes[selected_class_i].categoryDisplay);
- assignmentsTable.replaceData(currentTableData.currentTermData.classes[selected_class_i].assignments);
+ assignmentsTable.replaceData(currentTableData.currentTermData
+ .classes[selected_class_i].assignments
+ .filter(({ placeholder }) => !placeholder)
+ );
currentTableData.currentTermData.calcGPA = computeGPA(currentTableData.currentTermData.classes);
currentTableData.terms[currentTerm].calcGPA = computeGPA(currentTableData.currentTermData.classes);
@@ -281,6 +306,8 @@ let updateGradePage = function() {
$(".gpa_select-selected").html("Quarter GPA: " + GPA.percent);
$("#" + currentTerm).html("Quarter GPA: " + GPA.percent);
}
+
+ setup_tooltips();
}
let exportTableData = async function(prefs) {
diff --git a/public/js/home.js b/public/js/home.js
index 894787fb..47a23e09 100644
--- a/public/js/home.js
+++ b/public/js/home.js
@@ -36,6 +36,30 @@ let currentTableData = tableData[currentTableDataIndex];
let selected_class_i;
let termsReset = {};
+// Counter for creating new assignments
+var newAssignmentIDCounter = 0;
+
+// Registry for undos, contains assignment ID and the snackbar that corresponds to it
+// contains all the undo snackbars
+const undoData = []
+
+window.addEventListener("keydown", e => {
+ var evtobj = window.event || e
+ if (evtobj.keyCode == 90 && evtobj.ctrlKey && undoData.length !== 0) {
+ if (undoData[0].Snackbar !== undefined) {
+ undoData[0].Snackbar.destroy();
+ undoData[0].Snackbar = undefined;
+ }
+ replaceAssignmentFromID(
+ { assignment_id: undoData[0].assignment_id, placeholder: true },
+ undoData[0],
+ undoData[0].selected_class_i
+ );
+ undoData.shift();
+ }
+});
+
+
let tempCell;
// When the user clicks anywhere outside of a modal or dropdown, close it
window.addEventListener("click", function(event) {
@@ -366,7 +390,9 @@ let assignmentsTable = new Tabulator("#assignmentsTable", {
isNaN(cell.getRow().getData().score)
|| currentTableData.currentTermData
.classes[selected_class_i]
- .assignments[cell.getRow().getPosition()].synthetic
+ .assignments.filter(value =>
+ !value["placeholder"]
+ )[cell.getRow().getPosition()].synthetic
) ? "" : '',
width: 40,
align: "center",
@@ -581,7 +607,53 @@ let assignmentsTable = new Tabulator("#assignmentsTable", {
width: 40,
align: "center",
cellClick: function(e, cell) {
- cell.getRow().delete();
+ const data = cell.getRow().getData();
+ replaceAssignmentFromID(data, {assignment_id: data["assignment_id"], placeholder: true}, selected_class_i);
+
+ const undoSnackbar = new Snackbar(
+ `You deleted ${data["name"]}`, {
+ color: "var(--red1)",
+ textColor: "var(--white)",
+ buttonText: "Undo",
+
+ // Replace the assignment with a placeholder that just
+ // contains the assignment ID
+ buttonClick: () => {
+ // Get index for splicing and comparing
+ index = undoData.findIndex(a =>
+ a.assignment_id === data.assignment_id);
+ arrData = undoData[index];
+ // Remove snackbar before putting data back
+ arrData.Snackbar = undefined;
+ replaceAssignmentFromID({
+ assignment_id: arrData.assignment_id,
+ placeholder: true,
+ }, arrData, arrData.selected_class_i);
+ undoData.splice(index, 1);
+ },
+ timeout: 7500,
+
+ // On either a timeout or a bodyclick removes the
+ // snackbar link
+ timeoutFunction: () => {
+ undoData[
+ undoData.map(arrData => arrData.assignment_id)
+ .indexOf(data.assignment_id)
+ ].Snackbar = undefined;
+ },
+
+ bodyClick: () => {
+ undoData[
+ undoData.map(arrData => arrData.assignment_id)
+ .indexOf(data.assignment_id)
+ ].Snackbar = undefined;
+ },
+ }
+ ).show();
+
+ data.Snackbar = undoSnackbar;
+ data.selected_class_i = selected_class_i;
+ undoData.unshift(data);
},
headerSort: false,
cssClass: "icon-col allow-overflow"
diff --git a/public/js/snackbar.js b/public/js/snackbar.js
new file mode 100644
index 00000000..52948d9c
--- /dev/null
+++ b/public/js/snackbar.js
@@ -0,0 +1,264 @@
+/** snackbar.js
+ * Has all the snackbar class code in it
+ * To use the snackbar, just give it some text, give it some options (or not) and .show() it.
+ * It goes a little more into detail in the descriptions of each function. It also has a nice
+ * little jsdoc thing if you're using something that supports it. If not, sucks for you.
+ */
+class Snackbar {
+
+ /**
+ * snackbars contains key value pairs of snackbar IDs and snackbar references respectively
+ * if you ever need to get a snackbar from an ID
+ * snackbarIDs makes sure the IDs are unique
+ */
+ static snackbars = {};
+ static snackbarIDs = [];
+
+ /**
+ * state IDs
+ * DESTROYED means it doesn't exist in html
+ * HIDDEN means it exists but is hidden
+ * SHOWN means it exists and is shown
+ */
+ static DESTROYED = 0
+ static HIDDEN = 1
+ static SHOWN = 2
+
+ /**
+ * text is the main requirement, and it's just text
+ * color: String - string reference to a color or a variable, sets the background color
+ * textColor: String - string reference to a color or a variable, sets the text color
+ * buttonText: String - Sets the button text, both it and buttonClick have to be defined for the button to show
+ * buttonClick: Function - Sets the button's onclick logic, both it and buttonText have to be defined for the button to show
+ * destroyWhenButtonClicked : Boolean - Whether or not it should destroy itself when the button is clicked, defaults to true
+ * bodyClick: Function - Sets the body's onclick logic
+ * destroyWhenBodyClicked : Boolean - Whether or not it should destroy itself when the body is clicked, defaults to true
+ * timeout: Int - Time in ms
+ * timeoutFunction: Function - What to run on timeout (doesn't run if hidden or destroyed)
+ * timeoutMode: can be "destroy", "hide", "none" or empty. Determines what to do on timeout, destroys by default
+ */
+ constructor(text, options = {}) {
+ this.text = text;
+ this.color = options["color"];
+ this.textColor = options["textColor"];
+ this.buttonText = options["buttonText"];
+ this.buttonClick = options["buttonClick"];
+ this.destroyWhenButtonClicked = options["destroyWhenButtonClicked"] || true;
+ this.bodyClick = options["bodyClick"];
+ this.destroyWhenBodyClicked = options["destroyWhenBodyClicked"] || true;
+
+ //timeout logic
+ this.timeoutFunction = options["timeoutFunction"] !== undefined ? options["timeoutFunction"] : () => {};
+ this.timeout = options["timeout"];
+ this.timeoutInProgress;
+
+ //what to run on timeout
+ this.timeoutEndFunction;
+ switch(options["timeoutMode"]) {
+ case "destroy":
+ case undefined:
+ this.timeoutEndFunction = () => this.destroy();
+ break;
+ case "hide":
+ this.timeoutEndFunction = () => this.hide();
+ break;
+ case "none":
+ this.timeoutEndFunction = () => {};
+ break;
+ }
+
+ //creates this.id
+ this.id;
+
+ //sets state to destroyed
+ this.state = Snackbar.DESTROYED
+
+ }
+
+ /**
+ * creates the snackbar in the HTML
+ * if you want to show it as soon as you make it, use show without calling make instead
+ * returns the snackbar object
+ */
+ make() {
+ //stops if the element already exists
+ if (typeof document.getElementById(`sidenav-${this.id}`) === undefined) {
+ return;
+ }
+
+ //gives it an ID if it doesn't already have one
+ //this can happen if the snackbar object still exists but has been destroyed
+ if (this.id === undefined) {
+ this.createID();
+ }
+
+ //creates the snackbar and gives it classes
+ const snackbarNode = document.createElement("DIV");
+ snackbarNode.classList.add("snackbar");
+ snackbarNode.classList.add("hidden");
+
+ //assigns its id based off of it's actual id
+ snackbarNode.id = `snackbar-${this.id}`;
+
+ //adds color if given
+ if (this.color !== undefined) {
+ snackbarNode.style.backgroundColor = this.color;
+ }
+
+ //sets the body onclick listener which just destroys it by default
+ const bodyOnClickFunction = this.bodyClick !== undefined ? () => this.bodyClick() : () => {};
+ const destroyFromBody = this.destroyWhenBodyClicked ? () => this.destroy() : () => {};
+ snackbarNode.addEventListener("click", () => {
+ bodyOnClickFunction();
+ destroyFromBody();
+ })
+
+ //adds the text
+ const textNode = document.createElement("SPAN");
+ textNode.textContent = this.text;
+
+ //colors the text if necessary
+ if (this.textColor !== undefined) {
+ textNode.style.color = this.textColor;
+ }
+
+ //adds the text node
+ snackbarNode.appendChild(textNode);
+
+ //makes the button if given button parameters
+ if (this.buttonText !== undefined && this.buttonClick != undefined) {
+ //creates the button and adds class
+ const buttonNode = document.createElement("BUTTON");
+
+ //creates the text span
+ const buttonTextNode = document.createElement("SPAN");
+ buttonTextNode.textContent = this.buttonText;
+
+ //colors the text if necessary
+ if (this.textColor !== undefined) {
+ buttonTextNode.style.color = this.textColor;
+ }
+
+ if (this.color !== undefined) {
+ buttonNode.style.backgroundColor = this.color;
+ }
+
+ //adds the text node
+ buttonNode.appendChild(buttonTextNode);
+
+ //sets the button onclick listener which runs the given funtion and destroys the snackar by default
+ const destroyFromButton = this.destroyWhenButtonClicked ? () => this.destroy() : () => {};
+ buttonNode.addEventListener("click", event => {
+ this.buttonClick();
+ destroyFromButton();
+ //stops propogation so the body event isn't called
+ event.stopPropagation();
+ })
+
+ snackbarNode.appendChild(buttonNode);
+ }
+
+ //adds the node to the body, puts reference to DOM element in this.element
+ document.body.appendChild(snackbarNode);
+ this.element = document.getElementById(`snackbar-${this.id}`);
+
+ this.state = Snackbar.HIDDEN
+ return this;
+ }
+
+ /**
+ * shows the snackbar
+ * if it's not already made, makes it
+ * if you need to show right after making, use this
+ * returns the snackbar object
+ */
+ show() {
+ //starts the timeout
+ if (this.timeout !== undefined) {
+ this.timeoutInProgress = setTimeout(() => {
+ this.timeoutFunction();
+ this.timeoutEndFunction();
+ this.timeoutInProgress = undefined; //resets the timeoutInProgress variable at the end of the timeout
+ }, this.timeout);
+ }
+
+ const removeHidden = () => {
+ this.state = Snackbar.SHOWN
+ this.element.classList.remove("hidden");
+ }
+
+ //if not already made, makes the snackbar
+ if (document.getElementById(`snackbar-${this.id}`) === null) {
+ this.make();
+ //waits a momement to make sure the snackbar's animation functions properly
+ setTimeout(removeHidden, 10);
+ return this;
+ } else {
+ removeHidden();
+ return this;
+ }
+ }
+
+ /**
+ * hides the snackbar
+ * returns the snackbar object
+ */
+ hide() {
+ if (this.timeoutInProgress !== undefined) {
+ clearTimeout(this.timeoutInProgress);
+ this.timeoutInProgress = undefined;
+ }
+
+ this.element.classList.add("hidden");
+ this.state = Snackbar.HIDDEN
+ return this;
+ }
+
+ /**
+ * destroys the snackbar, its references and its ID
+ * if it's not already hidden, hides it unless override is true
+ */
+ destroy() {
+ const snackbar = this;
+
+ //function which deletes the references and ids
+ const finalizeDeletion = function() {
+ snackbar.element.remove();
+ delete Snackbar.snackbarIDs[Snackbar.snackbarIDs.indexOf(snackbar.id)];
+ delete Snackbar.snackbars[snackbar.id];
+ snackbar.id = undefined;
+ }
+
+ this.state = Snackbar.DESTROYED
+
+ //if it's not hidden it shouldn't just dissapear
+ if (this.element.classList.contains("hidden")) {
+ finalizeDeletion();
+ } else {
+ this.hide();
+ //deletes it as soon as it's actually hidden
+ setTimeout(finalizeDeletion, 250);
+ }
+ }
+
+ /**
+ * creates and reserves the ID for this snackbar
+ * also creates its reference in snackbars
+ */
+ createID() {
+ let id = null;
+
+ //goes through all consecutive numbers to find an id
+ let iterator = 0
+ while (id === null) {
+ //checks if the id already exists, otherwise continues to iterate
+ Snackbar.snackbarIDs.includes(iterator) ? iterator++ : id = iterator;
+ }
+
+ Snackbar.snackbarIDs.push(id);
+ Snackbar.snackbars[id] = this;
+ this.id = id;
+
+ return id;
+ }
+}