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; + } +}