From 1c013aa8d8c58127c48df1a83c0aad5e6fb1a030 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Mon, 23 Dec 2024 14:58:39 -0800 Subject: [PATCH] Alert/Confirm Modals + Auto Revoke Certification Photo * If a Certified member deletes the final picture from their gallery page, their Certification Photo will be automatically rejected and they are instructed to begin the process again from the beginning. * Add nice Alert and Confirm modals around the website in place of the standard browser feature. Note: the inline confirm on submit buttons are still using the standard feature for now, as intercepting submit buttons named "intent" causes problems in getting the final form to submit. --- pkg/controller/photo/batch_edit.go | 11 ++- pkg/models/certification.go | 60 ++++++++++++ pkg/templates/templates.go | 1 + web/static/js/alert-modal.js | 108 ++++++++++++++++++++++ web/static/js/likes.js | 4 +- web/templates/account/age_gate.html | 5 +- web/templates/account/dashboard.html | 76 ++++++++------- web/templates/account/signup.html | 9 +- web/templates/admin/add_user.html | 4 +- web/templates/base.html | 3 + web/templates/forum/new_post.html | 11 ++- web/templates/partials/alert_modal.html | 30 ++++++ web/templates/partials/like_modal.html | 4 +- web/templates/partials/mark_explicit.html | 9 +- web/templates/photo/gallery.html | 4 +- web/templates/photo/upload.html | 2 +- 16 files changed, 282 insertions(+), 59 deletions(-) create mode 100644 web/static/js/alert-modal.js create mode 100644 web/templates/partials/alert_modal.html diff --git a/pkg/controller/photo/batch_edit.go b/pkg/controller/photo/batch_edit.go index 6c79967..061b215 100644 --- a/pkg/controller/photo/batch_edit.go +++ b/pkg/controller/photo/batch_edit.go @@ -171,7 +171,7 @@ func batchDeletePhotos( } { if len(filename) > 0 { if err := pphoto.Delete(filename); err != nil { - log.Error("Delete Photo: couldn't remove file from disk: %s: %s", filename, err) + session.FlashError(w, r, "Delete Photo: couldn't remove file from disk: %s: %s", filename, err) } } } @@ -191,6 +191,15 @@ func batchDeletePhotos( } } + // Maybe revoke their Certified status if they have cleared out their gallery. + for _, owner := range owners { + if revoked := models.MaybeRevokeCertificationForEmptyGallery(owner); revoked { + if owner.ID == currentUser.ID { + session.FlashError(w, r, "Notice: because you have deleted your entire photo gallery, your Certification status has been automatically revoked.") + } + } + } + session.Flash(w, r, "%d photo(s) deleted!", len(photos)) } diff --git a/pkg/models/certification.go b/pkg/models/certification.go index e4865ab..278ebd3 100644 --- a/pkg/models/certification.go +++ b/pkg/models/certification.go @@ -2,8 +2,10 @@ package models import ( "errors" + "fmt" "time" + "code.nonshy.com/nonshy/website/pkg/log" "gorm.io/gorm" ) @@ -101,6 +103,64 @@ func CountCertificationPhotosNeedingApproval() int64 { return count } +// MaybeRevokeCertificationForEmptyGallery will delete a user's certification photo if they delete every picture from their gallery. +// +// Returns true if their certification was revoked. +func MaybeRevokeCertificationForEmptyGallery(user *User) bool { + cert, err := GetCertificationPhoto(user.ID) + if err != nil { + return false + } + + // Ignore if their cert photo status is not applicable to be revoked. + if cert.Status == CertificationPhotoNeeded || cert.Status == CertificationPhotoRejected { + return false + } + + if count := CountPhotos(user.ID); count == 0 { + // Revoke their cert status. + cert.Status = CertificationPhotoRejected + cert.SecondaryVerified = false + cert.AdminComment = "Your certification photo has been automatically rejected because you have deleted every photo on your gallery. " + + "To restore your certified status, please upload photos to your gallery and submit a new Certification Photo for approval." + + if err := cert.Save(); err != nil { + log.Error("MaybeRevokeCertificationForEmptyGallery(%s): %s", user.Username, err) + } + + // Update the user's Certified flag. Note: we freshly query the user here in case they had JUST deleted + // their default profile picture - so that we don't (re)set their old ProfilePhotoID by accident! + if user, err := GetUser(user.ID); err == nil { + user.Certified = false + if err := user.Save(); err != nil { + log.Error("MaybeRevokeCertificationForEmptyGallery(%s): saving user certified flag: %s", user.Username, err) + } + } + + // Notify the site admin for visibility. + fb := &Feedback{ + Intent: "report", + Subject: "A certified user has deleted all their pictures", + UserID: user.ID, + TableName: "users", + TableID: user.ID, + Message: fmt.Sprintf( + "The username **@%s** has deleted every picture in their gallery, and so their Certification Photo status has been revoked.", + user.Username, + ), + } + + // Save the feedback. + if err := CreateFeedback(fb); err != nil { + log.Error("Couldn't save feedback from user auto-revoking their cert photo: %s", err) + } + + return true + } + + return false +} + // Save photo. func (p *CertificationPhoto) Save() error { result := DB.Save(p) diff --git a/pkg/templates/templates.go b/pkg/templates/templates.go index 259ae7b..1056bae 100644 --- a/pkg/templates/templates.go +++ b/pkg/templates/templates.go @@ -162,6 +162,7 @@ func (t *Template) Reload() error { // Base template layout. var baseTemplates = []string{ config.TemplatePath + "/base.html", + config.TemplatePath + "/partials/alert_modal.html", config.TemplatePath + "/partials/user_avatar.html", config.TemplatePath + "/partials/like_modal.html", config.TemplatePath + "/partials/right_click.html", diff --git a/web/static/js/alert-modal.js b/web/static/js/alert-modal.js new file mode 100644 index 0000000..e729665 --- /dev/null +++ b/web/static/js/alert-modal.js @@ -0,0 +1,108 @@ +/** + * Alert and Confirm modals. + * + * Usage: + * + * modalAlert({message: "Hello world!"}).then(callback); + * modalConfirm({message: "Are you sure?"}).then(callback); + * + * Available options for modalAlert: + * - message + * - title: Alert + * + * Available options for modalConfirm: + * - message + * - title: Confirm + * - buttons: ["Ok", "Cancel"] + * - event (pass `event` for easy inline onclick handlers) + * - element (pass `this` for easy inline onclick handlers) + * + * Example onclick for modalConfirm: + * + * + * + * The `element` is used to find the nearest
and submit it on OK. + * The `event` is used to cancel the submit button's default. + */ +document.addEventListener('DOMContentLoaded', () => { + const $modal = document.querySelector("#nonshy-alert-modal"), + $ok = $modal.querySelector("button.nonshy-alert-ok-button"), + $cancel = $modal.querySelector("button.nonshy-alert-cancel-button"), + $title = $modal.querySelector("#nonshy-alert-modal-title"), + $body = $modal.querySelector("#nonshy-alert-modal-body"), + cls = 'is-active'; + + // Current caller's promise. + var currentPromise = null; + + const hideModal = () => { + currentPromise = null; + $modal.classList.remove(cls); + }; + + const showModal = ({ + message, + title="Alert", + isConfirm=false, + buttons=["Ok", "Cancel"], + }) => { + $ok.innerHTML = buttons[0]; + $cancel.innerHTML = buttons[1]; + $cancel.style.display = isConfirm ? "" : "none"; + + // Strip HTML from message but allow line breaks. + message = message.replace(//g, ">"); + message = message.replace(/\n/g, "
"); + + $title.innerHTML = title; + $body.innerHTML = message; + + // Show the modal. + $modal.classList.add(cls); + + // Return as a promise. + return new Promise((resolve, reject) => { + currentPromise = resolve; + }); + }; + + // Click events for the modal buttons. + $ok.addEventListener('click', (e) => { + if (currentPromise !== null) { + currentPromise(); + } + hideModal(); + }); + $cancel.addEventListener('click', (e) => { + hideModal(); + }); + + // Key bindings to dismiss the modal. + window.addEventListener('keydown', (e) => { + if ($modal.classList.contains(cls)) { + if (e.key == 'Enter') { + $ok.click(); + } else if (e.key == 'Escape') { + $cancel.click(); + } + } + }); + + // Exported global functions to invoke the modal. + window.modalAlert = async ({ message, title="Alert" }) => { + return showModal({ + message, + title, + isConfirm: false, + }); + }; + window.modalConfirm = async ({ message, title="Confirm", buttons=["Ok", "Cancel"] }) => { + return showModal({ + message, + title, + isConfirm: true, + buttons, + }); + }; +}); diff --git a/web/static/js/likes.js b/web/static/js/likes.js index 9666561..5d7791b 100644 --- a/web/static/js/likes.js +++ b/web/static/js/likes.js @@ -43,7 +43,7 @@ document.addEventListener('DOMContentLoaded', () => { .then((response) => response.json()) .then((data) => { if (data.StatusCode !== 200) { - window.alert(data.data.error); + modalAlert({message: data.data.error}); return; } @@ -54,7 +54,7 @@ document.addEventListener('DOMContentLoaded', () => { $label.innerHTML = `Like (${likes})`; } }).catch(resp => { - window.alert(resp); + console.error("Like:", resp); }).finally(() => { busy = false; }) diff --git a/web/templates/account/age_gate.html b/web/templates/account/age_gate.html index bdcc205..4354c4a 100644 --- a/web/templates/account/age_gate.html +++ b/web/templates/account/age_gate.html @@ -85,15 +85,16 @@ $dob = document.querySelector("#dob"); $manualEntry.addEventListener("click", function(e) { + $manualEntry.blur(); e.preventDefault(); let answer = window.prompt("Enter your birthdate in 'YYYY-MM-DD' format").trim().replace(/\//g, '-'); if (answer.match(/^(\d{2})-(\d{2})-(\d{4})/)) { let group = answer.match(/^(\d{2})-(\d{2})-(\d{4})/); answer = `${group[3]}-${group[1]}-${group[2]}`; - window.alert(`NOTE: Your input was interpreted to be in MM/DD/YYYY order and has been read as: ${answer}`); + modalAlert({message: `NOTE: Your input was interpreted to be in MM/DD/YYYY order and has been read as: ${answer}`}); } else if (!answer.match(/^\d{4}-\d{2}-\d{2}/)) { - window.alert(`Please enter the date in YYYY-MM-DD format.`); + modalAlert({message: `Please enter the date in YYYY-MM-DD format.`}); return; } diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index af4334c..2b6a9be 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -777,40 +777,45 @@ document.addEventListener('DOMContentLoaded', () => { $deleteButton.addEventListener("click", (e) => { e.stopPropagation(); e.preventDefault(); + + let callback = () => { + busy = true; + return fetch("/v1/notifications/delete", { + method: "POST", + mode: "same-origin", + cache: "no-cache", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + "id": parseInt(ID), + }), + }) + .then((response) => response.json()) + .then((data) => { + console.log(data); + + // Hide the notification row immediately. + node.style.display = 'none'; + }).catch(resp => { + console.error(resp); + }).finally(() => { + busy = false; + }); + }; + if (!dontAskAgain) { - if (!window.confirm( - "Do you want to DELETE this notification?\n\nNote: If you click Ok, you will not be asked "+ - "the next time you want to delete another notification until your next page reload." - )) { - return; - } - dontAskAgain = true; + modalConfirm({ + message: "Do you want to DELETE this notification?\n\nNote: If you click Ok, you will not be asked "+ + "the next time you want to delete another notification until your next page reload.", + }).then(() => { + dontAskAgain = true; + callback(); + }); + } else { + callback(); } - - busy = true; - return fetch("/v1/notifications/delete", { - method: "POST", - mode: "same-origin", - cache: "no-cache", - credentials: "same-origin", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - "id": parseInt(ID), - }), - }) - .then((response) => response.json()) - .then((data) => { - console.log(data); - - // Hide the notification row immediately. - node.style.display = 'none'; - }).catch(resp => { - window.alert(resp); - }).finally(() => { - busy = false; - }); }); // If the notification doesn't have a "NEW!" badge, no action needed. @@ -853,7 +858,7 @@ document.addEventListener('DOMContentLoaded', () => { .then((data) => { console.log(data); }).catch(resp => { - window.alert(resp); + console.error(resp); }).finally(() => { busy = false; if (href !== undefined && href !== "#") { @@ -875,9 +880,10 @@ document.addEventListener('DOMContentLoaded', () => { const link = node.dataset.link, prompt = node.dataset.confirm.replace(/\\n/g, "\n"); - if (!window.confirm(prompt)) return; - window.location = link; + modalConfirm({message: prompt}).then(() => { + window.location = link; + }); }); }); }); diff --git a/web/templates/account/signup.html b/web/templates/account/signup.html index 62ec1b7..8838885 100644 --- a/web/templates/account/signup.html +++ b/web/templates/account/signup.html @@ -253,7 +253,7 @@ window.addEventListener("DOMContentLoaded", (event) => { .then((response) => response.json()) .then((data) => { if (data.StatusCode !== 200) { - window.alert(data.data.error); + modalAlert({message: data.data.error}); return; } @@ -266,7 +266,7 @@ window.addEventListener("DOMContentLoaded", (event) => { $unError.style.display = "block"; } }).catch(resp => { - window.alert(resp); + console.error(resp); }).finally(() => { $unCheck.style.display = "none"; }) @@ -283,15 +283,16 @@ window.addEventListener("DOMContentLoaded", (event) => { if ($manualEntry != undefined) { $manualEntry.addEventListener("click", function(e) { + $manualEntry.blur(); e.preventDefault(); let answer = window.prompt("Enter your birthdate in 'YYYY-MM-DD' format").trim().replace(/\//g, '-'); if (answer.match(/^(\d{2})-(\d{2})-(\d{4})/)) { let group = answer.match(/^(\d{2})-(\d{2})-(\d{4})/); answer = `${group[3]}-${group[1]}-${group[2]}`; - window.alert(`NOTE: Your input was interpreted to be in MM/DD/YYYY order and has been read as: ${answer}`); + modalAlert({message: `NOTE: Your input was interpreted to be in MM/DD/YYYY order and has been read as: ${answer}`}); } else if (!answer.match(/^\d{4}-\d{2}-\d{2}/)) { - window.alert(`Please enter the date in YYYY-MM-DD format.`); + modalAlert({message: `Please enter the date in YYYY-MM-DD format.`}); return; } diff --git a/web/templates/admin/add_user.html b/web/templates/admin/add_user.html index ba760e8..406e959 100644 --- a/web/templates/admin/add_user.html +++ b/web/templates/admin/add_user.html @@ -126,7 +126,7 @@ .then((response) => response.json()) .then((data) => { if (data.StatusCode !== 200) { - window.alert(data.data.error); + modalAlert({message: data.data.error}); return; } @@ -139,7 +139,7 @@ $unError.style.display = "block"; } }).catch(resp => { - window.alert(resp); + console.error(resp); }).finally(() => { $unCheck.style.display = "none"; }) diff --git a/web/templates/base.html b/web/templates/base.html index 6539d3c..8d70df6 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -404,6 +404,9 @@ {{- end}} {{template "scripts" .}} + + {{template "alert-modal"}} + {{template "like-modal"}} diff --git a/web/templates/forum/new_post.html b/web/templates/forum/new_post.html index 80f1ad1..adfdf31 100644 --- a/web/templates/forum/new_post.html +++ b/web/templates/forum/new_post.html @@ -296,7 +296,7 @@ @@ -430,10 +430,11 @@ removeOption(idx) { if (this.answers.length <= 2) { - if (!window.confirm("A poll needs at least two options. Remove the poll?")) { - return; - } - this.removePoll(); + modalConfirm({ + message: "A poll needs at least two options. Remove the poll?", + }).then(() => { + this.removePoll(); + }) return; } this.answers.splice(idx, 1); diff --git a/web/templates/partials/alert_modal.html b/web/templates/partials/alert_modal.html new file mode 100644 index 0000000..1472c59 --- /dev/null +++ b/web/templates/partials/alert_modal.html @@ -0,0 +1,30 @@ + + +{{define "alert-modal"}} + + +{{end}} diff --git a/web/templates/partials/like_modal.html b/web/templates/partials/like_modal.html index 396c9ce..77fa29f 100644 --- a/web/templates/partials/like_modal.html +++ b/web/templates/partials/like_modal.html @@ -187,7 +187,7 @@ .then((response) => response.json()) .then((data) => { if (data.StatusCode !== 200) { - window.alert(data.data.error); + modalAlert({message: data.data.error}); return; } @@ -196,7 +196,7 @@ this.total = data.data.pager.Total; this.result = likes; }).catch(resp => { - window.alert(resp); + console.error(resp); }).finally(() => { this.busy = false; }); diff --git a/web/templates/partials/mark_explicit.html b/web/templates/partials/mark_explicit.html index 58de9c5..c3f3d77 100644 --- a/web/templates/partials/mark_explicit.html +++ b/web/templates/partials/mark_explicit.html @@ -166,7 +166,7 @@ document.addEventListener("DOMContentLoaded", () => { .then((response) => response.json()) .then((data) => { if (data.StatusCode !== 200) { - window.alert(data.data.error); + modalAlert({message: data.data.error}); return; } showHide(false); @@ -182,10 +182,13 @@ document.addEventListener("DOMContentLoaded", () => { } setTimeout(() => { - window.alert("This photo has been flagged to be marked as Explicit. Thanks for your help!"); + modalAlert({ + message: "This photo has been flagged to be marked as Explicit. Thanks for your help!", + title: "Marked Explicit!", + }); }, 200); }).catch(resp => { - window.alert(resp); + modalAlert({message: resp}); }); }; diff --git a/web/templates/photo/gallery.html b/web/templates/photo/gallery.html index b38a654..7ec77e7 100644 --- a/web/templates/photo/gallery.html +++ b/web/templates/photo/gallery.html @@ -913,7 +913,7 @@ $modalImg.title = altText; $altText.style.display = altText ? "block" : "none"; $altText.onclick = (e) => { - window.alert(altText); + modalAlert({message: altText, title: "Alt Text"}); e.preventDefault(); e.stopPropagation(); return false; @@ -936,7 +936,7 @@ console.error("When marking photo %d as viewed: status code %d: %s", photoID, data.StatusCode, data.data.error); return; } - }).catch(window.alert); + }).catch(console.error); } document.querySelectorAll(".js-modal-trigger").forEach(node => { diff --git a/web/templates/photo/upload.html b/web/templates/photo/upload.html index 8096f5b..386b92b 100644 --- a/web/templates/photo/upload.html +++ b/web/templates/photo/upload.html @@ -568,7 +568,7 @@ let onFile = (file) => { // Too large? (8 MB GIFs) - NOTE: also see config.go so this matches. if (file.size >= 1024*1024*8) { - window.alert("That file is too large! Choose something less than 8 MB."); + modalAlert({message: "That file is too large! Choose something less than 8 MB."}); return; }