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{
|
||||
Page: 1,
|
||||
PerPage: config.PageSizeDashboardNotifications,
|
||||
Sort: "created_at desc",
|
||||
Sort: "read, created_at desc",
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
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.
|
||||
//
|
||||
// DEPRECATED: send them to the batch-edit endpoint.
|
||||
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) {
|
||||
// Query params.
|
||||
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
|
||||
}
|
||||
templates.Redirect(w, fmt.Sprintf("/photo/batch-edit?intent=delete&id=%s", r.FormValue("id")))
|
||||
})
|
||||
}
|
||||
|
|
|
@ -108,6 +108,17 @@ func GetPhotos(IDs []uint64) (map[uint64]*Photo, 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.
|
||||
//
|
||||
// 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("/photo/edit", middleware.LoginRequired(photo.Edit()))
|
||||
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("GET /photo/private", middleware.LoginRequired(photo.Private()))
|
||||
mux.Handle("/photo/private/share", middleware.LoginRequired(photo.Share()))
|
||||
|
|
|
@ -4,10 +4,33 @@
|
|||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<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>
|
||||
</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">
|
||||
<h1>Certification Required</h1>
|
||||
<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 -->
|
||||
{{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}}">
|
||||
<span class="icon"><i class="fa fa-edit"></i></span>
|
||||
<span>Edit</span>
|
||||
|
@ -490,6 +495,9 @@
|
|||
|
||||
{{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) -->
|
||||
{{if eq .ViewStyle "full"}}
|
||||
{{range .Photos}}
|
||||
|
@ -753,22 +761,117 @@
|
|||
|
||||
{{SimplePager .Pager}}
|
||||
|
||||
<!-- Admin change log link -->
|
||||
{{if .CurrentUser.HasAdminScope "admin.changelog"}}
|
||||
<div class="block">
|
||||
<a href="/admin/changelog?table_name=photos{{if .User}}&about_user_id={{.User.ID}}{{end}}" class="button is-small has-text-warning">
|
||||
<span class="icon"><i class="fa fa-peace mr-1"></i></span>
|
||||
<span>{{if .User}}User{{else}}Site{{end}} Gallery change log</span>
|
||||
</a>
|
||||
<!-- 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>
|
||||
{{end}}
|
||||
|
||||
</form><!-- end gallery form for batch edits -->
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Admin change log link -->
|
||||
{{if .CurrentUser.HasAdminScope "admin.changelog"}}
|
||||
<div class="block">
|
||||
<a href="/admin/changelog?table_name=photos{{if .User}}&about_user_id={{.User.ID}}{{end}}" class="button is-small has-text-warning">
|
||||
<span class="icon"><i class="fa fa-peace mr-1"></i></span>
|
||||
<span>{{if .User}}User{{else}}Site{{end}} Gallery change log</span>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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", () => {
|
||||
// Get our modal to trigger it on click of a detail img.
|
||||
let $modal = document.querySelector("#detail-modal"),
|
||||
|
|
Loading…
Reference in New Issue
Block a user