Batch Edit/Delete Photos + Misc Fixes
Certification Required page: * Show helpful advice if the reason for the page is only that the user had deleted their default profile pic, but their account was certified. Batch Photo Delete & Visibility: * On user galleries, owners and admins can batch Delete or Set Visibility on many photos at once. Checkboxes appear in the edit/delete row of each photo, and bulk actions appear at the bottom of the page along with select/unselect all boxes. * Deprecated the old /photo/delete endpoint: it now redirects to the batch delete page with the one photo ID. Misc Changes: * Notifications now sort unread to the top always.
This commit is contained in:
parent
cbdabe791e
commit
8078ff8755
|
@ -50,7 +50,7 @@ func Dashboard() http.HandlerFunc {
|
||||||
pager := &models.Pagination{
|
pager := &models.Pagination{
|
||||||
Page: 1,
|
Page: 1,
|
||||||
PerPage: config.PageSizeDashboardNotifications,
|
PerPage: config.PageSizeDashboardNotifications,
|
||||||
Sort: "created_at desc",
|
Sort: "read, created_at desc",
|
||||||
}
|
}
|
||||||
pager.ParsePage(r)
|
pager.ParsePage(r)
|
||||||
notifs, err := models.PaginateNotifications(currentUser, nf, pager)
|
notifs, err := models.PaginateNotifications(currentUser, nf, pager)
|
||||||
|
|
244
pkg/controller/photo/batch_edit.go
Normal file
244
pkg/controller/photo/batch_edit.go
Normal file
|
@ -0,0 +1,244 @@
|
||||||
|
package photo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
pphoto "code.nonshy.com/nonshy/website/pkg/photo"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BatchEdit controller (/photo/batch-edit?id=N) to change properties about your picture.
|
||||||
|
func BatchEdit() http.HandlerFunc {
|
||||||
|
// Reuse the upload page but with an EditPhoto variable.
|
||||||
|
tmpl := templates.Must("photo/batch_edit.html")
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
// Form params
|
||||||
|
intent = r.FormValue("intent")
|
||||||
|
photoIDs []uint64
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect the photo ID params.
|
||||||
|
if value, ok := r.Form["id"]; ok {
|
||||||
|
for _, idStr := range value {
|
||||||
|
if photoID, err := strconv.Atoi(idStr); err == nil {
|
||||||
|
photoIDs = append(photoIDs, uint64(photoID))
|
||||||
|
} else {
|
||||||
|
log.Error("parsing photo ID %s: %s", idStr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Error("photoIDs: %+v", photoIDs)
|
||||||
|
|
||||||
|
// Validation.
|
||||||
|
if len(photoIDs) == 0 || len(photoIDs) > 100 {
|
||||||
|
session.FlashError(w, r, "Invalid number of photo IDs.")
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find these photos by ID.
|
||||||
|
photos, err := models.GetPhotos(photoIDs)
|
||||||
|
if err != nil {
|
||||||
|
templates.NotFoundPage(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the current user.
|
||||||
|
currentUser, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// In case an admin is editing this photo: remember the HTTP request current user,
|
||||||
|
// before the currentUser may be set to the photo's owner below.
|
||||||
|
// var requestUser = currentUser
|
||||||
|
|
||||||
|
// Validate permission to edit all of these photos.
|
||||||
|
var (
|
||||||
|
ownerIDs []uint64
|
||||||
|
mapPhotos = map[uint64]*models.Photo{}
|
||||||
|
)
|
||||||
|
for _, photo := range photos {
|
||||||
|
mapPhotos[photo.ID] = photo
|
||||||
|
|
||||||
|
if !photo.CanBeEditedBy(currentUser) {
|
||||||
|
templates.ForbiddenPage(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerIDs = append(ownerIDs, photo.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the photo owners.
|
||||||
|
var (
|
||||||
|
owners, _ = models.MapUsers(currentUser, ownerIDs)
|
||||||
|
wasShy = map[uint64]bool{} // record if this change may make them shy
|
||||||
|
redirectURI = "/" // go first owner's gallery
|
||||||
|
|
||||||
|
// Are any of them a user's profile photo? (map userID->true) so we know
|
||||||
|
// who to unlink the picture from first and avoid a postgres error.
|
||||||
|
wasUserProfilePicture = map[uint64]bool{}
|
||||||
|
)
|
||||||
|
for _, user := range owners {
|
||||||
|
redirectURI = fmt.Sprintf("/u/%s/photos", user.Username)
|
||||||
|
wasShy[user.ID] = user.IsShy()
|
||||||
|
|
||||||
|
// Check if this user's profile ID is being deleted.
|
||||||
|
if user.ProfilePhotoID != nil {
|
||||||
|
if _, ok := mapPhotos[*user.ProfilePhotoID]; ok {
|
||||||
|
wasUserProfilePicture[user.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm batch deletion or edit.
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
|
||||||
|
confirm := r.PostFormValue("confirm") == "true"
|
||||||
|
if !confirm {
|
||||||
|
session.FlashError(w, r, "Confirm you want to delete this photo.")
|
||||||
|
templates.Redirect(w, redirectURI)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which intent are they executing on?
|
||||||
|
switch intent {
|
||||||
|
case "delete":
|
||||||
|
batchDeletePhotos(w, r, currentUser, photos, wasUserProfilePicture, owners, redirectURI)
|
||||||
|
case "visibility":
|
||||||
|
batchUpdateVisibility(w, r, currentUser, photos, owners)
|
||||||
|
default:
|
||||||
|
session.FlashError(w, r, "Unknown intent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maybe kick them from chat if this deletion makes them into a Shy Account.
|
||||||
|
for _, user := range owners {
|
||||||
|
user.FlushCaches()
|
||||||
|
if !wasShy[user.ID] && user.IsShy() {
|
||||||
|
if _, err := chat.MaybeDisconnectUser(user); err != nil {
|
||||||
|
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the user to their gallery.
|
||||||
|
templates.Redirect(w, redirectURI)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var vars = map[string]interface{}{
|
||||||
|
"Intent": intent,
|
||||||
|
"Photos": photos,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch DELETE executive handler.
|
||||||
|
func batchDeletePhotos(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
currentUser *models.User,
|
||||||
|
photos map[uint64]*models.Photo,
|
||||||
|
wasUserProfilePicture map[uint64]bool,
|
||||||
|
owners map[uint64]*models.User,
|
||||||
|
redirectURI string,
|
||||||
|
) {
|
||||||
|
// Delete all the photos.
|
||||||
|
for _, photo := range photos {
|
||||||
|
|
||||||
|
// Was this someone's profile picture ID?
|
||||||
|
if wasUserProfilePicture[photo.UserID] {
|
||||||
|
log.Debug("Delete Photo: was the user's profile photo, unset ProfilePhotoID")
|
||||||
|
if owner, ok := owners[photo.UserID]; ok {
|
||||||
|
if err := owner.RemoveProfilePhoto(); err != nil {
|
||||||
|
session.FlashError(w, r, "Error unsetting your current profile photo: %s", err)
|
||||||
|
templates.Redirect(w, redirectURI)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the images from disk.
|
||||||
|
for _, filename := range []string{
|
||||||
|
photo.Filename,
|
||||||
|
photo.CroppedFilename,
|
||||||
|
} {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take back notifications on it.
|
||||||
|
models.RemoveNotification("photos", photo.ID)
|
||||||
|
|
||||||
|
if err := photo.Delete(); err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't delete photo: %s", err)
|
||||||
|
templates.Redirect(w, redirectURI)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
if owner, ok := owners[photo.UserID]; ok {
|
||||||
|
models.LogDeleted(owner, currentUser, "photos", photo.ID, "Deleted the photo.", photo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Flash(w, r, "%d photo(s) deleted!", len(photos))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch DELETE executive handler.
|
||||||
|
func batchUpdateVisibility(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
currentUser *models.User,
|
||||||
|
photos map[uint64]*models.Photo,
|
||||||
|
owners map[uint64]*models.User,
|
||||||
|
) {
|
||||||
|
// Visibility setting.
|
||||||
|
visibility := r.PostFormValue("visibility")
|
||||||
|
|
||||||
|
// Delete all the photos.
|
||||||
|
for _, photo := range photos {
|
||||||
|
|
||||||
|
// Diff for the ChangeLog.
|
||||||
|
diffs := []models.FieldDiff{
|
||||||
|
models.NewFieldDiff("Visibility", photo.Visibility, visibility),
|
||||||
|
}
|
||||||
|
|
||||||
|
photo.Visibility = models.PhotoVisibility(visibility)
|
||||||
|
|
||||||
|
// If going private, take back notifications on it.
|
||||||
|
if photo.Visibility == models.PhotoPrivate {
|
||||||
|
models.RemoveNotification("photos", photo.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := photo.Save(); err != nil {
|
||||||
|
session.FlashError(w, r, "Error saving photo #%d: %s", photo.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
if owner, ok := owners[photo.UserID]; ok {
|
||||||
|
// Log the change.
|
||||||
|
models.LogUpdated(owner, currentUser, "photos", photo.ID, "Updated the photo's settings.", diffs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Flash(w, r, "%d photo(s) updated!", len(photos))
|
||||||
|
}
|
|
@ -246,127 +246,10 @@ func Edit() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete controller (/photo/Delete?id=N) to change properties about your picture.
|
// Delete controller (/photo/Delete?id=N) to change properties about your picture.
|
||||||
|
//
|
||||||
|
// DEPRECATED: send them to the batch-edit endpoint.
|
||||||
func Delete() http.HandlerFunc {
|
func Delete() http.HandlerFunc {
|
||||||
// Reuse the upload page but with an EditPhoto variable.
|
|
||||||
tmpl := templates.Must("photo/delete.html")
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Query params.
|
templates.Redirect(w, fmt.Sprintf("/photo/batch-edit?intent=delete&id=%s", r.FormValue("id")))
|
||||||
photoID, err := strconv.Atoi(r.FormValue("id"))
|
|
||||||
if err != nil {
|
|
||||||
log.Error("photo.Delete: failed to parse `id` param (%s) as int: %s", r.FormValue("id"), err)
|
|
||||||
session.FlashError(w, r, "Photo 'id' parameter required.")
|
|
||||||
templates.Redirect(w, "/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Page to redirect to in case of errors.
|
|
||||||
redirect := fmt.Sprintf("%s?id=%d", r.URL.Path, photoID)
|
|
||||||
|
|
||||||
// Find this photo by ID.
|
|
||||||
photo, err := models.GetPhoto(uint64(photoID))
|
|
||||||
if err != nil {
|
|
||||||
templates.NotFoundPage(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the current user.
|
|
||||||
currentUser, err := session.CurrentUser(r)
|
|
||||||
if err != nil {
|
|
||||||
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
|
||||||
templates.Redirect(w, "/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// In case an admin is editing this photo: remember the HTTP request current user,
|
|
||||||
// before the currentUser may be set to the photo's owner below.
|
|
||||||
var requestUser = currentUser
|
|
||||||
|
|
||||||
// Do we have permission for this photo?
|
|
||||||
if photo.UserID != currentUser.ID {
|
|
||||||
if !currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
|
||||||
templates.ForbiddenPage(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the owner of this photo and assume currentUser is them for the remainder
|
|
||||||
// of this controller.
|
|
||||||
if user, err := models.GetUser(photo.UserID); err != nil {
|
|
||||||
session.FlashError(w, r, "Couldn't get the owner User for this photo!")
|
|
||||||
templates.Redirect(w, "/")
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
currentUser = user
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm deletion?
|
|
||||||
if r.Method == http.MethodPost {
|
|
||||||
// Record if this change is going to make them a Shy Account.
|
|
||||||
var wasShy = currentUser.IsShy()
|
|
||||||
|
|
||||||
confirm := r.PostFormValue("confirm") == "true"
|
|
||||||
if !confirm {
|
|
||||||
session.FlashError(w, r, "Confirm you want to delete this photo.")
|
|
||||||
templates.Redirect(w, redirect)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Was this our profile picture?
|
|
||||||
if currentUser.ProfilePhotoID != nil && *currentUser.ProfilePhotoID == photo.ID {
|
|
||||||
log.Debug("Delete Photo: was the user's profile photo, unset ProfilePhotoID")
|
|
||||||
if err := currentUser.RemoveProfilePhoto(); err != nil {
|
|
||||||
session.FlashError(w, r, "Error unsetting your current profile photo: %s", err)
|
|
||||||
templates.Redirect(w, redirect)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the images from disk.
|
|
||||||
for _, filename := range []string{
|
|
||||||
photo.Filename,
|
|
||||||
photo.CroppedFilename,
|
|
||||||
} {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Take back notifications on it.
|
|
||||||
models.RemoveNotification("photos", photo.ID)
|
|
||||||
|
|
||||||
if err := photo.Delete(); err != nil {
|
|
||||||
session.FlashError(w, r, "Couldn't delete photo: %s", err)
|
|
||||||
templates.Redirect(w, redirect)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the change.
|
|
||||||
models.LogDeleted(currentUser, requestUser, "photos", photo.ID, "Deleted the photo.", photo)
|
|
||||||
|
|
||||||
session.Flash(w, r, "Photo deleted!")
|
|
||||||
|
|
||||||
// Maybe kick them from chat if this deletion makes them into a Shy Account.
|
|
||||||
currentUser.FlushCaches()
|
|
||||||
if !wasShy && currentUser.IsShy() {
|
|
||||||
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
|
|
||||||
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the user to their gallery.
|
|
||||||
templates.Redirect(w, "/u/"+currentUser.Username+"/photos")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
|
||||||
"Photo": photo,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -108,6 +108,17 @@ func GetPhotos(IDs []uint64) (map[uint64]*Photo, error) {
|
||||||
return mp, result.Error
|
return mp, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanBeEditedBy checks whether a photo can be edited by the current user.
|
||||||
|
//
|
||||||
|
// Admins with PhotoModerator scope can always edit.
|
||||||
|
func (p *Photo) CanBeEditedBy(currentUser *User) bool {
|
||||||
|
if currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.UserID == currentUser.ID
|
||||||
|
}
|
||||||
|
|
||||||
// CanBeSeenBy checks whether a photo can be seen by the current user.
|
// CanBeSeenBy checks whether a photo can be seen by the current user.
|
||||||
//
|
//
|
||||||
// An admin user with omni photo view permission can always see the photo.
|
// An admin user with omni photo view permission can always see the photo.
|
||||||
|
|
|
@ -63,6 +63,7 @@ func New() http.Handler {
|
||||||
mux.Handle("GET /photo/view", middleware.LoginRequired(photo.View()))
|
mux.Handle("GET /photo/view", middleware.LoginRequired(photo.View()))
|
||||||
mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit()))
|
mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit()))
|
||||||
mux.Handle("/photo/delete", middleware.LoginRequired(photo.Delete()))
|
mux.Handle("/photo/delete", middleware.LoginRequired(photo.Delete()))
|
||||||
|
mux.Handle("/photo/batch-edit", middleware.LoginRequired(photo.BatchEdit()))
|
||||||
mux.Handle("/photo/certification", middleware.LoginRequired(photo.Certification()))
|
mux.Handle("/photo/certification", middleware.LoginRequired(photo.Certification()))
|
||||||
mux.Handle("GET /photo/private", middleware.LoginRequired(photo.Private()))
|
mux.Handle("GET /photo/private", middleware.LoginRequired(photo.Private()))
|
||||||
mux.Handle("/photo/private/share", middleware.LoginRequired(photo.Share()))
|
mux.Handle("/photo/private/share", middleware.LoginRequired(photo.Share()))
|
||||||
|
|
|
@ -4,10 +4,33 @@
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="title">Certification Required</h1>
|
<h1 class="title">Certification Required</h1>
|
||||||
|
{{if and .CurrentUser.Certified (not .CurrentUser.ProfilePhoto.ID)}}
|
||||||
|
<h2 class="subtitle">You are just missing a default profile photo!</h2>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Just missing a cert photo? -->
|
||||||
|
{{if and .CurrentUser.Certified (not .CurrentUser.ProfilePhoto.ID)}}
|
||||||
|
<div class="notification is-success is-light content">
|
||||||
|
<p>
|
||||||
|
<strong>Notice:</strong> your Certification Photo is OK, you are just missing a profile picture!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
To maintain your <strong>certified</strong> status on this website, you are required to keep a
|
||||||
|
<strong>default profile picture</strong> set on your account at all times.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Please <a href="/u/{{.CurrentUser.Username}}/photos">visit your Photo Gallery</a> for instructions
|
||||||
|
to set one of your existing photos as your default, or
|
||||||
|
<a href="/photo/upload?intent=profile_pic">upload a new profile picture.</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="block content p-4 mb-0">
|
<div class="block content p-4 mb-0">
|
||||||
<h1>Certification Required</h1>
|
<h1>Certification Required</h1>
|
||||||
<p>
|
<p>
|
||||||
|
|
169
web/templates/photo/batch_edit.html
Normal file
169
web/templates/photo/batch_edit.html
Normal file
|
@ -0,0 +1,169 @@
|
||||||
|
{{define "title"}}Delete Photo{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero is-link is-bold">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">
|
||||||
|
{{if eq .Intent "delete"}}
|
||||||
|
<i class="fa fa-trash mr-2"></i>
|
||||||
|
Delete {{len .Photos}} Photo{{Pluralize (len .Photos)}}
|
||||||
|
{{else if eq .Intent "visibility"}}
|
||||||
|
<i class="fa fa-eye mr-2"></i>
|
||||||
|
Edit Visibility
|
||||||
|
{{else}}
|
||||||
|
Batch Edit Photos
|
||||||
|
{{end}}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="block p-4">
|
||||||
|
<div class="level">
|
||||||
|
<div class="level-item">
|
||||||
|
<div class="card" style="max-width: 800px">
|
||||||
|
<header class="card-header {{if eq .Intent "delete"}}has-background-danger{{else}}has-background-link{{end}}">
|
||||||
|
<p class="card-header-title has-text-light">
|
||||||
|
{{if eq .Intent "delete"}}
|
||||||
|
<span class="icon"><i class="fa fa-trash"></i></span>
|
||||||
|
Delete {{len .Photos}} Photo{{Pluralize (len .Photos)}}
|
||||||
|
{{else if eq .Intent "visibility"}}
|
||||||
|
<span class="icon"><i class="fa fa-eye mr-2"></i></span>
|
||||||
|
Edit Visibility
|
||||||
|
{{else}}
|
||||||
|
Batch Edit Photos
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div class="card-content">
|
||||||
|
<form method="POST" action="/photo/batch-edit">
|
||||||
|
{{InputCSRF}}
|
||||||
|
<input type="hidden" name="intent" value="{{.Intent}}">
|
||||||
|
<input type="hidden" name="confirm" value="true">
|
||||||
|
|
||||||
|
<!-- Bulk Visibility Settings -->
|
||||||
|
{{if eq .Intent "visibility"}}
|
||||||
|
<p>
|
||||||
|
You may use this page to set <strong>all ({{len .Photos}}) photo{{if ge (len .Photos) 2}}s'{{end}}</strong>
|
||||||
|
visibility setting.
|
||||||
|
</p>
|
||||||
|
<!-- TODO: copy/pasted block from the Upload page -->
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Photo Visibility</label>
|
||||||
|
<div>
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio"
|
||||||
|
name="visibility"
|
||||||
|
value="public"
|
||||||
|
{{if or (not .EditPhoto) (eq .EditPhoto.Visibility "public")}}checked{{end}}>
|
||||||
|
<strong class="has-text-link ml-1">
|
||||||
|
<span>Public <small>(members only)</small></span>
|
||||||
|
<span class="icon"><i class="fa fa-eye"></i></span>
|
||||||
|
</strong>
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
This photo will appear on your profile page and can be seen by any
|
||||||
|
logged-in user account. It may also appear on the site-wide Photo
|
||||||
|
Gallery if that option is enabled, below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio"
|
||||||
|
name="visibility"
|
||||||
|
value="friends"
|
||||||
|
{{if eq .EditPhoto.Visibility "friends"}}checked{{end}}>
|
||||||
|
<strong class="has-text-warning ml-1">
|
||||||
|
<span>Friends only</span>
|
||||||
|
<span class="icon"><i class="fa fa-user-group"></i></span>
|
||||||
|
</strong>
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
Only users you have accepted as a friend can see this photo on your
|
||||||
|
profile page and on the site-wide Photo Gallery if that option is
|
||||||
|
enabled, below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio"
|
||||||
|
name="visibility"
|
||||||
|
value="private"
|
||||||
|
{{if eq .EditPhoto.Visibility "private"}}checked{{end}}>
|
||||||
|
<strong class="has-text-private ml-1">
|
||||||
|
<span>Private</span>
|
||||||
|
<span class="icon"><i class="fa fa-lock"></i></span>
|
||||||
|
</strong>
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
This photo is visible only to you and to users for whom you have
|
||||||
|
granted access
|
||||||
|
(<a href="/photo/private" target="_blank" class="has-text-private">manage grants <i class="fa fa-external-link"></i></a>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="has-text-warning is-size-7 mt-4">
|
||||||
|
<i class="fa fa-info-circle mr-1"></i>
|
||||||
|
<strong class="has-text-warning">Reminder:</strong> There are risks inherent with sharing
|
||||||
|
pictures on the Internet, and {{PrettyTitle}} can't guarantee that another member of the site
|
||||||
|
won't download and possibly redistribute your photos. You may mark your picture as "Friends only"
|
||||||
|
or "Private" to limit who on the website will see it, but anybody who <em>can</em> see it could potentially
|
||||||
|
save it to their computer. <a href="/faq#downloading" target="_blank">Learn more <i class="fa fa-external-link"></i></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Show range of photos on all updates -->
|
||||||
|
<div class="columns is-mobile is-multiline">
|
||||||
|
{{range .Photos}}
|
||||||
|
<div class="column is-half">
|
||||||
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
<div class="image block">
|
||||||
|
<!-- GIF video? -->
|
||||||
|
{{if HasSuffix .Filename ".mp4"}}
|
||||||
|
<video autoplay loop controls controlsList="nodownload" playsinline>
|
||||||
|
<source src="{{PhotoURL .Filename}}" type="video/mp4">
|
||||||
|
</video>
|
||||||
|
{{else}}
|
||||||
|
<img src="{{PhotoURL .Filename}}">
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
Are you sure you want to
|
||||||
|
{{if eq .Intent "delete"}}
|
||||||
|
<strong class="has-text-danger">delete</strong>
|
||||||
|
{{else if eq .Intent "visibility"}}
|
||||||
|
<strong>update the visibility</strong> of
|
||||||
|
{{else}}
|
||||||
|
update
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if ge (len .Photos) 2 -}}
|
||||||
|
these <strong>{{len .Photos}} photos?</strong>
|
||||||
|
{{- else -}}
|
||||||
|
this photo?
|
||||||
|
{{- end}}
|
||||||
|
</div>
|
||||||
|
<div class="block has-text-center">
|
||||||
|
{{if eq .Intent "delete"}}
|
||||||
|
<button type="submit" class="button is-danger">Delete Photo{{Pluralize (len .Photos)}}</button>
|
||||||
|
{{else}}
|
||||||
|
<button type="submit" class="button is-primary">Update Photo{{Pluralize (len .Photos)}}</button>
|
||||||
|
{{end}}
|
||||||
|
<button type="button" class="button" onclick="history.back()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
|
@ -73,6 +73,11 @@
|
||||||
|
|
||||||
<!-- Reusable card footer -->
|
<!-- Reusable card footer -->
|
||||||
{{define "card-footer"}}
|
{{define "card-footer"}}
|
||||||
|
<label class="card-footer-item checkbox">
|
||||||
|
<input type="checkbox" class="nonshy-edit-photo-id"
|
||||||
|
name="id"
|
||||||
|
value="{{.ID}}">
|
||||||
|
</label>
|
||||||
<a class="card-footer-item" href="/photo/edit?id={{.ID}}">
|
<a class="card-footer-item" href="/photo/edit?id={{.ID}}">
|
||||||
<span class="icon"><i class="fa fa-edit"></i></span>
|
<span class="icon"><i class="fa fa-edit"></i></span>
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
|
@ -490,6 +495,9 @@
|
||||||
|
|
||||||
{{SimplePager .Pager}}
|
{{SimplePager .Pager}}
|
||||||
|
|
||||||
|
<!-- Form to wrap the gallery, e.g. for batch edits on user views. -->
|
||||||
|
<form action="/photo/batch-edit">
|
||||||
|
|
||||||
<!-- "Full" view style? (blog style) -->
|
<!-- "Full" view style? (blog style) -->
|
||||||
{{if eq .ViewStyle "full"}}
|
{{if eq .ViewStyle "full"}}
|
||||||
{{range .Photos}}
|
{{range .Photos}}
|
||||||
|
@ -753,6 +761,45 @@
|
||||||
|
|
||||||
{{SimplePager .Pager}}
|
{{SimplePager .Pager}}
|
||||||
|
|
||||||
|
<!-- Bulk user actions to their photos -->
|
||||||
|
{{if or .IsOwnPhotos (.CurrentUser.HasAdminScope "social.moderator.photo")}}
|
||||||
|
<hr>
|
||||||
|
<div class="columns is-multiline is-mobile my-4">
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<div class="buttons has-addons">
|
||||||
|
<button type="button" class="button" id="nonshy-select-all">
|
||||||
|
<i class="fa fa-square-check"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="button" id="nonshy-select-none">
|
||||||
|
<i class="fa fa-square"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column" id="nonshy-edit-buttons">
|
||||||
|
<button type="submit" class="button is-small is-danger is-outlined"
|
||||||
|
name="intent"
|
||||||
|
value="delete">
|
||||||
|
<i class="fa fa-trash mr-2"></i>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="submit" class="button mx-1 is-small is-info is-outlined"
|
||||||
|
name="intent"
|
||||||
|
value="visibility">
|
||||||
|
<i class="fa fa-eye mr-2"></i>
|
||||||
|
Edit Visibility
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span id="nonshy-count-selected" class="is-size-7 ml-2"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form><!-- end gallery form for batch edits -->
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<!-- Admin change log link -->
|
<!-- Admin change log link -->
|
||||||
{{if .CurrentUser.HasAdminScope "admin.changelog"}}
|
{{if .CurrentUser.HasAdminScope "admin.changelog"}}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
|
@ -764,11 +811,67 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
|
||||||
|
{{if or .IsOwnPhotos (.CurrentUser.HasAdminScope "social.moderator.photo")}}
|
||||||
|
// Batch edit controls
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const checkboxes = document.getElementsByClassName("nonshy-edit-photo-id"),
|
||||||
|
$checkAll = document.querySelector("#nonshy-select-all"),
|
||||||
|
$checkNone = document.querySelector("#nonshy-select-none"),
|
||||||
|
$countSelected = document.querySelector("#nonshy-count-selected"),
|
||||||
|
$submitButtons = document.querySelector("#nonshy-edit-buttons");
|
||||||
|
|
||||||
|
$submitButtons.style.display = "none";
|
||||||
|
|
||||||
|
const setAllChecked = (v) => {
|
||||||
|
for (let box of checkboxes) {
|
||||||
|
box.checked = v;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const areAnyChecked = () => {
|
||||||
|
let any = false,
|
||||||
|
count = 0;
|
||||||
|
for (let box of checkboxes) {
|
||||||
|
if (box.checked) {
|
||||||
|
any = true;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the selected count
|
||||||
|
$countSelected.innerHTML = count > 0 ? `${count} selected.` : "";
|
||||||
|
$countSelected.style.display = count > 0 ? "" : "none";
|
||||||
|
return any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showHideButtons = () => {
|
||||||
|
$submitButtons.style.display = areAnyChecked() ? "" : "none";
|
||||||
|
};
|
||||||
|
showHideButtons();
|
||||||
|
|
||||||
|
// Check/Uncheck All buttons.
|
||||||
|
$checkAll.addEventListener("click", (e) => {
|
||||||
|
setAllChecked(true);
|
||||||
|
showHideButtons();
|
||||||
|
});
|
||||||
|
$checkNone.addEventListener("click", (e) => {
|
||||||
|
setAllChecked(false);
|
||||||
|
showHideButtons();
|
||||||
|
});
|
||||||
|
|
||||||
|
// When checkboxes are toggled.
|
||||||
|
for (let box of checkboxes) {
|
||||||
|
box.addEventListener("change", (e) => {
|
||||||
|
showHideButtons();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{{end}}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
// Get our modal to trigger it on click of a detail img.
|
// Get our modal to trigger it on click of a detail img.
|
||||||
let $modal = document.querySelector("#detail-modal"),
|
let $modal = document.querySelector("#detail-modal"),
|
||||||
|
|
Loading…
Reference in New Issue
Block a user