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.
This commit is contained in:
Noah Petherbridge 2024-12-23 14:58:39 -08:00
parent 24230915b6
commit 1c013aa8d8
16 changed files with 282 additions and 59 deletions

View File

@ -171,7 +171,7 @@ func batchDeletePhotos(
} { } {
if len(filename) > 0 { if len(filename) > 0 {
if err := pphoto.Delete(filename); err != nil { 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)) session.Flash(w, r, "%d photo(s) deleted!", len(photos))
} }

View File

@ -2,8 +2,10 @@ package models
import ( import (
"errors" "errors"
"fmt"
"time" "time"
"code.nonshy.com/nonshy/website/pkg/log"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -101,6 +103,64 @@ func CountCertificationPhotosNeedingApproval() int64 {
return count 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. // Save photo.
func (p *CertificationPhoto) Save() error { func (p *CertificationPhoto) Save() error {
result := DB.Save(p) result := DB.Save(p)

View File

@ -162,6 +162,7 @@ func (t *Template) Reload() error {
// Base template layout. // Base template layout.
var baseTemplates = []string{ var baseTemplates = []string{
config.TemplatePath + "/base.html", config.TemplatePath + "/base.html",
config.TemplatePath + "/partials/alert_modal.html",
config.TemplatePath + "/partials/user_avatar.html", config.TemplatePath + "/partials/user_avatar.html",
config.TemplatePath + "/partials/like_modal.html", config.TemplatePath + "/partials/like_modal.html",
config.TemplatePath + "/partials/right_click.html", config.TemplatePath + "/partials/right_click.html",

View File

@ -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:
*
* <button onclick="modalConfirm({message: 'Are you sure?', event, element}">Delete</button>
*
* The `element` is used to find the nearest <form> 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, "&lt;");
message = message.replace(/>/g, "&gt;");
message = message.replace(/\n/g, "<br>");
$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,
});
};
});

View File

@ -43,7 +43,7 @@ document.addEventListener('DOMContentLoaded', () => {
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
if (data.StatusCode !== 200) { if (data.StatusCode !== 200) {
window.alert(data.data.error); modalAlert({message: data.data.error});
return; return;
} }
@ -54,7 +54,7 @@ document.addEventListener('DOMContentLoaded', () => {
$label.innerHTML = `Like (${likes})`; $label.innerHTML = `Like (${likes})`;
} }
}).catch(resp => { }).catch(resp => {
window.alert(resp); console.error("Like:", resp);
}).finally(() => { }).finally(() => {
busy = false; busy = false;
}) })

View File

@ -85,15 +85,16 @@
$dob = document.querySelector("#dob"); $dob = document.querySelector("#dob");
$manualEntry.addEventListener("click", function(e) { $manualEntry.addEventListener("click", function(e) {
$manualEntry.blur();
e.preventDefault(); e.preventDefault();
let answer = window.prompt("Enter your birthdate in 'YYYY-MM-DD' format").trim().replace(/\//g, '-'); let answer = window.prompt("Enter your birthdate in 'YYYY-MM-DD' format").trim().replace(/\//g, '-');
if (answer.match(/^(\d{2})-(\d{2})-(\d{4})/)) { if (answer.match(/^(\d{2})-(\d{2})-(\d{4})/)) {
let group = 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]}`; 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}/)) { } 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; return;
} }

View File

@ -777,16 +777,8 @@ document.addEventListener('DOMContentLoaded', () => {
$deleteButton.addEventListener("click", (e) => { $deleteButton.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
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;
}
let callback = () => {
busy = true; busy = true;
return fetch("/v1/notifications/delete", { return fetch("/v1/notifications/delete", {
method: "POST", method: "POST",
@ -807,10 +799,23 @@ document.addEventListener('DOMContentLoaded', () => {
// Hide the notification row immediately. // Hide the notification row immediately.
node.style.display = 'none'; node.style.display = 'none';
}).catch(resp => { }).catch(resp => {
window.alert(resp); console.error(resp);
}).finally(() => { }).finally(() => {
busy = false; busy = false;
}); });
};
if (!dontAskAgain) {
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();
}
}); });
// If the notification doesn't have a "NEW!" badge, no action needed. // If the notification doesn't have a "NEW!" badge, no action needed.
@ -853,7 +858,7 @@ document.addEventListener('DOMContentLoaded', () => {
.then((data) => { .then((data) => {
console.log(data); console.log(data);
}).catch(resp => { }).catch(resp => {
window.alert(resp); console.error(resp);
}).finally(() => { }).finally(() => {
busy = false; busy = false;
if (href !== undefined && href !== "#") { if (href !== undefined && href !== "#") {
@ -875,11 +880,12 @@ document.addEventListener('DOMContentLoaded', () => {
const link = node.dataset.link, const link = node.dataset.link,
prompt = node.dataset.confirm.replace(/\\n/g, "\n"); prompt = node.dataset.confirm.replace(/\\n/g, "\n");
if (!window.confirm(prompt)) return;
modalConfirm({message: prompt}).then(() => {
window.location = link; window.location = link;
}); });
}); });
}); });
});
</script> </script>
{{end}} {{end}}

View File

@ -253,7 +253,7 @@ window.addEventListener("DOMContentLoaded", (event) => {
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
if (data.StatusCode !== 200) { if (data.StatusCode !== 200) {
window.alert(data.data.error); modalAlert({message: data.data.error});
return; return;
} }
@ -266,7 +266,7 @@ window.addEventListener("DOMContentLoaded", (event) => {
$unError.style.display = "block"; $unError.style.display = "block";
} }
}).catch(resp => { }).catch(resp => {
window.alert(resp); console.error(resp);
}).finally(() => { }).finally(() => {
$unCheck.style.display = "none"; $unCheck.style.display = "none";
}) })
@ -283,15 +283,16 @@ window.addEventListener("DOMContentLoaded", (event) => {
if ($manualEntry != undefined) { if ($manualEntry != undefined) {
$manualEntry.addEventListener("click", function(e) { $manualEntry.addEventListener("click", function(e) {
$manualEntry.blur();
e.preventDefault(); e.preventDefault();
let answer = window.prompt("Enter your birthdate in 'YYYY-MM-DD' format").trim().replace(/\//g, '-'); let answer = window.prompt("Enter your birthdate in 'YYYY-MM-DD' format").trim().replace(/\//g, '-');
if (answer.match(/^(\d{2})-(\d{2})-(\d{4})/)) { if (answer.match(/^(\d{2})-(\d{2})-(\d{4})/)) {
let group = 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]}`; 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}/)) { } 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; return;
} }

View File

@ -126,7 +126,7 @@
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
if (data.StatusCode !== 200) { if (data.StatusCode !== 200) {
window.alert(data.data.error); modalAlert({message: data.data.error});
return; return;
} }
@ -139,7 +139,7 @@
$unError.style.display = "block"; $unError.style.display = "block";
} }
}).catch(resp => { }).catch(resp => {
window.alert(resp); console.error(resp);
}).finally(() => { }).finally(() => {
$unCheck.style.display = "none"; $unCheck.style.display = "none";
}) })

View File

@ -404,6 +404,9 @@
{{- end}} {{- end}}
{{template "scripts" .}} {{template "scripts" .}}
<!-- Alert modal -->
{{template "alert-modal"}}
<!-- Likes modal --> <!-- Likes modal -->
{{template "like-modal"}} {{template "like-modal"}}

View File

@ -296,7 +296,7 @@
<button type="submit" <button type="submit"
name="intent" name="intent"
value="submit" value="submit"
class="button is-success"> class="button ml-4 is-success">
Post Message Post Message
</button> </button>
</div> </div>
@ -430,10 +430,11 @@
removeOption(idx) { removeOption(idx) {
if (this.answers.length <= 2) { if (this.answers.length <= 2) {
if (!window.confirm("A poll needs at least two options. Remove the poll?")) { modalConfirm({
return; message: "A poll needs at least two options. Remove the poll?",
} }).then(() => {
this.removePoll(); this.removePoll();
})
return; return;
} }
this.answers.splice(idx, 1); this.answers.splice(idx, 1);

View File

@ -0,0 +1,30 @@
<!-- "Alert" & "Confirm" modals to replace native browser features. -->
{{define "alert-modal"}}
<div class="modal nonshy-important-modal" id="nonshy-alert-modal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="card">
<div class="card-header has-background-warning">
<p class="card-header-title has-text-dark-dark">
<span id="nonshy-alert-modal-title">Alert</span>
</p>
</div>
<div class="card-content content">
<div id="nonshy-alert-modal-body">Alert</div>
</div>
<div class="card-footer has-text-centered">
<div class="card-footer-item">
<button type="button" class="button is-success nonshy-alert-ok-button">
Ok
</button>
<button type="button" class="button ml-4 nonshy-alert-cancel-button">
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript" src="/static/js/alert-modal.js?build={{.BuildHash}}"></script>
{{end}}

View File

@ -187,7 +187,7 @@
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
if (data.StatusCode !== 200) { if (data.StatusCode !== 200) {
window.alert(data.data.error); modalAlert({message: data.data.error});
return; return;
} }
@ -196,7 +196,7 @@
this.total = data.data.pager.Total; this.total = data.data.pager.Total;
this.result = likes; this.result = likes;
}).catch(resp => { }).catch(resp => {
window.alert(resp); console.error(resp);
}).finally(() => { }).finally(() => {
this.busy = false; this.busy = false;
}); });

View File

@ -166,7 +166,7 @@ document.addEventListener("DOMContentLoaded", () => {
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
if (data.StatusCode !== 200) { if (data.StatusCode !== 200) {
window.alert(data.data.error); modalAlert({message: data.data.error});
return; return;
} }
showHide(false); showHide(false);
@ -182,10 +182,13 @@ document.addEventListener("DOMContentLoaded", () => {
} }
setTimeout(() => { 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); }, 200);
}).catch(resp => { }).catch(resp => {
window.alert(resp); modalAlert({message: resp});
}); });
}; };

View File

@ -913,7 +913,7 @@
$modalImg.title = altText; $modalImg.title = altText;
$altText.style.display = altText ? "block" : "none"; $altText.style.display = altText ? "block" : "none";
$altText.onclick = (e) => { $altText.onclick = (e) => {
window.alert(altText); modalAlert({message: altText, title: "Alt Text"});
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
return false; return false;
@ -936,7 +936,7 @@
console.error("When marking photo %d as viewed: status code %d: %s", photoID, data.StatusCode, data.data.error); console.error("When marking photo %d as viewed: status code %d: %s", photoID, data.StatusCode, data.data.error);
return; return;
} }
}).catch(window.alert); }).catch(console.error);
} }
document.querySelectorAll(".js-modal-trigger").forEach(node => { document.querySelectorAll(".js-modal-trigger").forEach(node => {

View File

@ -568,7 +568,7 @@
let onFile = (file) => { let onFile = (file) => {
// Too large? (8 MB GIFs) - NOTE: also see config.go so this matches. // Too large? (8 MB GIFs) - NOTE: also see config.go so this matches.
if (file.size >= 1024*1024*8) { 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; return;
} }