Forum Photo Attachments

* Add support to upload a picture to forum posts and replies, in forums that
  have the PermitPhotos setting enabled.
* New DB table: CommentPhoto holds the association between a photo and a
  forum ID. Photos can be uploaded at preview time (before a CommentID is
  available) and get associated to the CommentID on save.
* Cron endpoint /v1/comment-photos/remove-orphaned can clean up orphaned
  photos without a CommentID older than 24 hours.
* Add "Photo Boards" as a default forum category for new boards.
This commit is contained in:
Noah 2022-10-20 21:02:30 -07:00
parent 6dcb0c66e8
commit 47f898561c
15 changed files with 624 additions and 12 deletions

View File

@ -112,6 +112,28 @@ the web app by using the admin controls on their profile page.
templates, issue redirects, error pages, ...
* `pkg/utility`: miscellaneous useful functions for the app.
## Cron API Endpoints
In settings.json get or configure the CronAPIKey (a UUID4 value is good and
the app generates a fresh one by default). The following are the cron API
endpoints that you may want to configure to run periodic maintenance tasks
on the app, such as to remove orphaned comment photos.
### GET /v1/comment-photos/remove-orphaned
Query parameters: `apiKey` which is the CronAPIKey.
This endpoint removes orphaned CommentPhotos (photo attachments to forum
posts). An orphaned photo is one that has no CommentID and was uploaded
older than 24 hours ago; e.g. a user uploaded a picture but then did not
complete the posting of their comment.
Suggested crontab:
```cron
0 2 * * * curl "http://localhost:8080/v1/comment-photos/remove-orphaned?apiKey=X"
```
## License
GPLv3.

View File

@ -92,6 +92,7 @@ var (
"Rules and Announcements",
"Nudists",
"Exhibitionists",
"Photo Boards",
"Anything Goes",
}
)

View File

@ -8,6 +8,7 @@ import (
"os"
"code.nonshy.com/nonshy/website/pkg/log"
"github.com/google/uuid"
)
// Current loaded settings.json
@ -17,6 +18,7 @@ var Current = DefaultVariable()
type Variable struct {
BaseURL string
AdminEmail string
CronAPIKey string
Mail Mail
Redis Redis
Database Database
@ -41,6 +43,7 @@ func DefaultVariable() Variable {
SQLite: "database.sqlite",
Postgres: "host=localhost user=nonshy password=nonshy dbname=nonshy port=5679 sslmode=disable TimeZone=America/Los_Angeles",
},
CronAPIKey: uuid.New().String(),
}
}

View File

@ -0,0 +1,93 @@
package api
import (
"fmt"
"net/http"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/photo"
)
// RemoveOrphanedCommentPhotos API.
//
// URL: /v1/comment-photos/remove-orphaned
//
// Query parameters: ?apiKey={CronAPIKey}
//
// This endpoint looks for CommentPhotos having a blank CommentID that were created older
// than 24 hours ago and removes them. Configure the "CronAPIKey" in your settings.json
// and pass it as the query parameter.
func RemoveOrphanedCommentPhotos() http.HandlerFunc {
// Response JSON schema.
type Response struct {
OK bool `json:"OK"`
Error string `json:"error,omitempty"`
Total int64 `json:"total"`
Removed int `json:"removed"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
SendJSON(w, http.StatusNotAcceptable, Response{
Error: "GET method only",
})
return
}
// Get and validate the API key.
var (
apiKey = r.FormValue("apiKey")
compare = config.Current.CronAPIKey
)
if compare == "" {
SendJSON(w, http.StatusInternalServerError, Response{
OK: false,
Error: "app CronAPIKey is not configured",
})
return
} else if apiKey == "" || apiKey != compare {
SendJSON(w, http.StatusInternalServerError, Response{
OK: false,
Error: "invalid apiKey query parameter",
})
return
}
// Do the needful.
photos, total, err := models.GetOrphanedCommentPhotos()
if err != nil {
SendJSON(w, http.StatusInternalServerError, Response{
OK: false,
Error: fmt.Sprintf("GetOrphanedCommentPhotos: %s", err),
})
return
}
for _, row := range photos {
if err := photo.Delete(row.Filename); err != nil {
SendJSON(w, http.StatusInternalServerError, Response{
OK: false,
Error: fmt.Sprintf("Photo ID %d: removing file %s: %s", row.ID, row.Filename, err),
})
return
}
if err := row.Delete(); err != nil {
SendJSON(w, http.StatusInternalServerError, Response{
OK: false,
Error: fmt.Sprintf("DeleteOrphanedCommentPhotos(%d): %s", row.ID, err),
})
return
}
}
// Send success response.
SendJSON(w, http.StatusOK, Response{
OK: true,
Total: total,
Removed: len(photos),
})
})
}

View File

@ -1,13 +1,18 @@
package forum
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/markdown"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
@ -23,6 +28,8 @@ func NewPost() http.HandlerFunc {
quoteCommentID = r.FormValue("quote") // add reply to thread while quoting a comment
editCommentID = r.FormValue("edit") // edit your comment
intent = r.FormValue("intent") // preview or submit
photoIntent = r.FormValue("photo_intent") // upload, remove photo attachment
photoID = r.FormValue("photo_id") // existing CommentPhoto ID
title = r.FormValue("title") // for new forum post only
message = r.PostFormValue("message") // comment body
isPinned = r.PostFormValue("pinned") == "true" // owners or admins only
@ -37,6 +44,9 @@ func NewPost() http.HandlerFunc {
// thread, we show and accept the thread settings to be updated as
// well (pinned, explicit, noreply)
isOriginalComment bool
// Attached photo object.
commentPhoto *models.CommentPhoto
)
// Get the current user.
@ -69,6 +79,19 @@ func NewPost() http.HandlerFunc {
}
}
// Does the comment have an existing Photo ID?
if len(photoID) > 0 {
if i, err := strconv.Atoi(photoID); err == nil {
if found, err := models.GetCommentPhoto(uint64(i)); err != nil {
session.FlashError(w, r, "Couldn't find comment photo ID #%d!", i)
templates.Redirect(w, fmt.Sprintf("/f/%s", forum.Fragment))
return
} else {
commentPhoto = found
}
}
}
// Are we pre-filling the message with a quotation of an existing comment?
if len(quoteCommentID) > 0 {
if i, err := strconv.Atoi(quoteCommentID); err == nil {
@ -95,6 +118,11 @@ func NewPost() http.HandlerFunc {
message = comment.Message
}
// Did this comment have a picture? Load it if so.
if photos, err := comment.GetPhotos(); err == nil && len(photos) > 0 {
commentPhoto = photos[0]
}
// Is this the OG thread of the post?
if thread.CommentID == comment.ID {
isOriginalComment = true
@ -130,8 +158,89 @@ func NewPost() http.HandlerFunc {
// Submitting the form.
if r.Method == http.MethodPost {
// Is a photo coming along?
if forum.PermitPhotos {
// Removing or replacing?
if photoIntent == "remove" || photoIntent == "replace" {
// Remove the attached photo.
if commentPhoto == nil {
session.FlashError(w, r, "Couldn't remove photo from post: no photo found!")
} else {
photo.Delete(commentPhoto.Filename)
if err := commentPhoto.Delete(); err != nil {
session.FlashError(w, r, "Couldn't remove photo from DB: %s", err)
} else {
session.Flash(w, r, "Photo attachment %sd from this post.", photoIntent)
commentPhoto = nil
}
}
}
// Uploading a new picture?
if photoIntent == "upload" || photoIntent == "replace" {
log.Info("Receiving a photo upload for forum post")
// Get their file upload.
file, header, err := r.FormFile("file")
if err != nil {
session.FlashError(w, r, "Error receiving your file: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
// Read the file contents.
log.Debug("Receiving uploaded file (%d bytes): %s", header.Size, header.Filename)
var buf bytes.Buffer
io.Copy(&buf, file)
filename, _, err := photo.UploadPhoto(photo.UploadConfig{
Extension: filepath.Ext(header.Filename),
Data: buf.Bytes(),
})
if err != nil {
session.FlashError(w, r, "Error in UploadPhoto: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
// Create the PhotoComment. If we don't have a Comment ID yet, let it be empty.
ptmpl := models.CommentPhoto{
Filename: filename,
}
if comment != nil {
ptmpl.CommentID = comment.ID
}
// Get the filesize.
if stat, err := os.Stat(photo.DiskPath(filename)); err == nil {
ptmpl.Filesize = stat.Size()
}
// Create it in DB!
p, err := models.CreateCommentPhoto(ptmpl)
if err != nil {
session.FlashError(w, r, "Couldn't create CommentPhoto in DB: %s", err)
} else {
log.Info("New photo! %+v", p)
}
commentPhoto = p
}
}
// Default intent is preview unless told to submit.
if intent == "submit" {
// A message OR a photo is required.
if forum.PermitPhotos && message == "" && commentPhoto == nil {
session.FlashError(w, r, "A message OR photo is required for this post.")
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return
} else if !forum.PermitPhotos && message == "" {
session.FlashError(w, r, "A message is required for this post.")
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return
}
// Are we modifying an existing comment?
if comment != nil {
comment.Message = message
@ -157,11 +266,19 @@ func NewPost() http.HandlerFunc {
// Are we replying to an existing thread?
if thread != nil {
if _, err := thread.Reply(currentUser, message); err != nil {
if reply, err := thread.Reply(currentUser, message); err != nil {
session.FlashError(w, r, "Couldn't add reply to thread: %s", err)
} else {
session.Flash(w, r, "Reply added to the thread!")
// If we're attaching a photo, link it to this reply CommentID.
if commentPhoto != nil {
commentPhoto.CommentID = reply.ID
if err := commentPhoto.Save(); err != nil {
log.Error("Couldn't save forum reply CommentPhoto.CommentID: %s", err)
}
}
// Notify watchers about this new post.
for _, userID := range models.GetSubscribers("threads", thread.ID) {
if userID == currentUser.ID {
@ -205,6 +322,14 @@ func NewPost() http.HandlerFunc {
} else {
session.Flash(w, r, "Thread created!")
// If we're attaching a photo, link it to this CommentID.
if commentPhoto != nil {
commentPhoto.CommentID = thread.CommentID
if err := commentPhoto.Save(); err != nil {
log.Error("Couldn't save forum post CommentPhoto.CommentID: %s", err)
}
}
// Subscribe the current user to responses on this thread.
if _, err := models.SubscribeTo(currentUser, "threads", thread.ID); err != nil {
log.Error("Couldn't subscribe user %d to forum thread %d: %s", currentUser.ID, thread.ID, err)
@ -229,6 +354,9 @@ func NewPost() http.HandlerFunc {
"IsPinned": isPinned,
"IsExplicit": isExplicit,
"IsNoReply": isNoReply,
// Attached photo.
"CommentPhoto": commentPhoto,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -81,6 +81,12 @@ func Thread() http.HandlerFunc {
}
commentLikeMap := models.MapLikes(currentUser, "comments", commentIDs)
// Get any photo attachments for these comments.
photos, err := models.MapCommentPhotos(comments)
if err != nil {
log.Error("Couldn't MapCommentPhotos: %s", err)
}
// Is the current user subscribed to notifications on this thread?
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
@ -89,6 +95,7 @@ func Thread() http.HandlerFunc {
"Thread": thread,
"Comments": comments,
"LikeMap": commentLikeMap,
"PhotoMap": photos,
"Pager": pager,
"IsSubscribed": isSubscribed,
}

112
pkg/models/comment_photo.go Normal file
View File

@ -0,0 +1,112 @@
package models
import (
"time"
)
// CommentPhoto table associates a photo attachment to a (forum) comment.
type CommentPhoto struct {
ID uint64 `gorm:"primaryKey"`
CommentID uint64 `gorm:"index"`
Filename string
Filesize int64
CreatedAt time.Time
UpdatedAt time.Time
ExpiredAt time.Time
}
// CreateCommentPhoto with most of the settings you want (not ID or timestamps) in the database.
func CreateCommentPhoto(tmpl CommentPhoto) (*CommentPhoto, error) {
p := &CommentPhoto{
CommentID: tmpl.CommentID,
Filename: tmpl.Filename,
}
result := DB.Create(p)
return p, result.Error
}
// GetCommentPhoto by ID.
func GetCommentPhoto(id uint64) (*CommentPhoto, error) {
p := &CommentPhoto{}
result := DB.First(&p, id)
return p, result.Error
}
// GetPhotos returns the comment photos for a given comment.
func (c *Comment) GetPhotos() ([]*CommentPhoto, error) {
mapping, err := MapCommentPhotos([]*Comment{c})
if err != nil {
return nil, err
}
return mapping.Get(c.ID), nil
}
// CommentPhotoMap maps comment IDs to CommentPhotos.
type CommentPhotoMap map[uint64][]*CommentPhoto
// Get like stats from the map.
func (lm CommentPhotoMap) Get(id uint64) []*CommentPhoto {
if stats, ok := lm[id]; ok {
return stats
}
return nil
}
// MapCommentPhotos returns a map of photo attachments to a series of comments.
func MapCommentPhotos(comments []*Comment) (CommentPhotoMap, error) {
var (
result = CommentPhotoMap{} // map[uint64][]*CommentPhoto{}
ps = []*CommentPhoto{}
IDs = []uint64{}
)
for _, c := range comments {
IDs = append(IDs, c.ID)
}
res := DB.Model(&CommentPhoto{}).Where("comment_id IN ?", IDs).Find(&ps)
if res.Error != nil {
return nil, res.Error
}
for _, row := range ps {
if _, ok := result[row.CommentID]; !ok {
result[row.CommentID] = []*CommentPhoto{}
}
result[row.CommentID] = append(result[row.CommentID], row)
}
return result, nil
}
// Save CommentPhoto.
func (p *CommentPhoto) Save() error {
result := DB.Save(p)
return result.Error
}
// Delete CommentPhoto.
func (p *CommentPhoto) Delete() error {
result := DB.Delete(p)
return result.Error
}
// GetOrphanedCommentPhotos gets all (up to 500) photos having a blank CommentID older than 24 hours.
func GetOrphanedCommentPhotos() ([]*CommentPhoto, int64, error) {
var (
count int64
cutoff = time.Now().Add(-24 * time.Hour)
ps = []*CommentPhoto{}
)
query := DB.Model(&CommentPhoto{}).Where("comment_id = 0 AND created_at < ?", cutoff)
query.Count(&count)
res := query.Limit(500).Find(&ps)
if res.Error != nil {
return nil, 0, res.Error
}
return ps, count, res.Error
}

View File

@ -23,4 +23,5 @@ func AutoMigrate() {
DB.AutoMigrate(&Like{})
DB.AutoMigrate(&Notification{})
DB.AutoMigrate(&Subscription{})
DB.AutoMigrate(&CommentPhoto{})
}

View File

@ -81,6 +81,7 @@ func New() http.Handler {
mux.HandleFunc("/v1/users/me", api.LoginOK())
mux.Handle("/v1/likes", middleware.LoginRequired(api.Likes()))
mux.Handle("/v1/notifications/read", middleware.LoginRequired(api.ReadNotification()))
mux.Handle("/v1/comment-photos/remove-orphaned", api.RemoveOrphanedCommentPhotos())
// Static files.
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(config.StaticPath))))

View File

@ -97,7 +97,6 @@
<a href="/photo/private">
<span class="icon"><i class="fa fa-eye"></i></span>
Manage Private Photos
<span class="tag is-success ml-1">NEW!</span>
</a>
</li>
<li>

View File

@ -89,6 +89,13 @@
<span>Privileged</span>
</span>
{{end}}
{{if .PermitPhotos}}
<span class="tag is-grey">
<span class="icon"><i class="fa fa-camera"></i></span>
<span>Photos</span>
</span>
{{end}}
</div>
</div>

View File

@ -54,7 +54,7 @@
</div>
{{end}}
<form action="/forum/post?to={{.Forum.Fragment}}{{if .Thread}}&thread={{.Thread.ID}}{{end}}{{if .EditCommentID}}&edit={{.EditCommentID}}{{end}}" method="POST">
<form action="/forum/post?to={{.Forum.Fragment}}{{if .Thread}}&thread={{.Thread.ID}}{{end}}{{if .EditCommentID}}&edit={{.EditCommentID}}{{end}}" method="POST" enctype="multipart/form-data">
{{InputCSRF}}
{{if not .Thread}}
@ -74,13 +74,65 @@
<textarea class="textarea" cols="80" rows="8"
name="message"
id="message"
required
{{if not .Forum.PermitPhotos}}required{{end}}
placeholder="Message">{{.Message}}</textarea>
<p class="help">
Markdown formatting supported.
</p>
</div>
<!-- Photo attachment widget -->
{{if .Forum.PermitPhotos}}
<!-- Intent: upload, remove, or replace -->
<input type="hidden" name="photo_intent" id="photoIntent">
<input type="hidden" name="photo_id" value="{{if .CommentPhoto}}{{.CommentPhoto.ID}}{{end}}">
<!-- Drag/Drop Modal -->
<div class="modal" id="drop-modal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box content has-text-centered">
<h1><i class="fa fa-upload mr-2"></i> Drop image to select it for upload</h1>
</div>
</div>
</div>
<div class="field block">
<label class="label">Photo Attachment</label>
<div class="file has-name is-fullwidth">
<label class="file-label">
<input class="file-input" type="file"
name="file"
id="file"
accept=".jpg,.jpeg,.jpe,.png">
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
<span class="file-label">
Choose a file…
</span>
</span>
<span class="file-name" id="fileName">
Select a file
</span>
</label>
</div>
</div>
<div class="box" id="imagePreview" {{if not .CommentPhoto}}style="display: none"{{end}}>
<h3 class="subtitle">
Selected image:
<button type="button" class="button is-danger is-small ml-4" id="removePhoto">
<i class="fa fa-trash"></i>
</button>
</h3>
<!-- Container of img tags for the selected photo preview. -->
<img id="previewImage"{{if .CommentPhoto}} src="{{PhotoURL .CommentPhoto.Filename}}"{{end}}>
</div>
{{end}}
{{if or (not .Thread) .EditThreadSettings}}
<div class="field block">
{{if or .CurrentUser.IsAdmin (and .Forum (eq .Forum.OwnerID .CurrentUser.ID))}}
@ -144,5 +196,85 @@
</div>
</div>
<!-- Script for photo upload on photo boards -->
{{if .Forum.PermitPhotos}}
<script type="text/javascript">
window.addEventListener("DOMContentLoaded", (event) => {
let $file = document.querySelector("#file"),
$fileName = document.querySelector("#fileName"),
$hiddenPreview = document.querySelector("#imagePreview"),
$previewImage = document.querySelector("#previewImage"),
$dropArea = document.querySelector("#drop-modal"),
$removePhoto = document.querySelector("#removePhoto"),
$photoIntent = document.querySelector("#photoIntent"),
$body = document.querySelector("body");
// Common handler for file selection (file input or drag/drop)
let onFile = (file) => {
$photoIntent.value = "{{if .CommentPhoto}}replace{{else}}upload{{end}}";
$fileName.innerHTML = file.name;
// Read the image to show the preview on-page.
const reader = new FileReader();
reader.addEventListener("load", () => {
const uploadedImg = reader.result;
$hiddenPreview.style.display = "block";
$previewImage.src = uploadedImg;
$previewImage.style.display = "block";
$previewImage.style.maxWidth = "100%";
$previewImage.style.height = "auto";
});
reader.readAsDataURL(file);
};
// Set up drag/drop file upload events.
$body.addEventListener("dragenter", function(e) {
e.preventDefault();
e.stopPropagation();
$dropArea.classList.add("is-active");
});
$body.addEventListener("dragover", function(e) {
e.preventDefault();
e.stopPropagation();
$dropArea.classList.add("is-active");
});
$body.addEventListener("dragleave", function(e) {
e.preventDefault();
e.stopPropagation();
$dropArea.classList.remove("is-active");
});
$body.addEventListener("drop", function(e) {
e.preventDefault();
e.stopPropagation();
$dropArea.classList.remove("is-active");
// Grab the file.
let dt = e.dataTransfer;
let file = dt.files[0];
// Set the file on the input field too.
$file.files = dt.files;
onFile(file);
});
// File input handler.
$file.addEventListener("change", function() {
let file = this.files[0];
onFile(file);
});
// Removal button.
$removePhoto.addEventListener("click", function() {
$photoIntent.value = "remove";
$fileName.innerHTML = "Select a file";
$file.value = '';
$hiddenPreview.style.display = 'none';
});
});
</script>
{{end}}
</div>
{{end}}

View File

@ -149,6 +149,22 @@
</div>
{{end}}
<!-- Photo attachments? -->
{{$Photos := $Root.PhotoMap.Get .ID}}
{{if $Photos}}
{{range $Photos}}
{{if not .ExpiredAt.IsZero}}
<div class="mt-4">
<span class="tag is-dark">photo expired on {{.ExpiredAt.Format "2006-01-02"}}</span>
</div>
{{else}}
<div class="image mt-4">
<img src="{{PhotoURL .Filename}}">
</div>
{{end}}
{{end}}
{{end}}
<hr class="has-background-grey mb-2">
<div class="columns is-mobile is-multiline is-size-7 mb-0">
@ -243,7 +259,7 @@
</header>
<div class="card-content">
<form action="/forum/post?to={{.Forum.Fragment}}&thread={{.Thread.ID}}" method="POST">
<form action="/forum/post?to={{.Forum.Fragment}}&thread={{.Thread.ID}}" method="POST" enctype="multipart/form-data">
{{InputCSRF}}
<div class="field block">
@ -251,13 +267,51 @@
<textarea class="textarea" cols="80" rows="6"
name="message"
id="message"
required
{{if not .Forum.PermitPhotos}}required{{end}}
placeholder="Message"></textarea>
<p class="help">
Markdown formatting supported.
</p>
</div>
<!-- Photo board that allows attachments? -->
{{if .Forum.PermitPhotos}}
<div class="field block mb-4">
<input type="hidden" name="photo_intent" id="photoIntent">
<!-- Drag/Drop Modal -->
<div class="modal" id="drop-modal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box content has-text-centered">
<h1><i class="fa fa-upload mr-2"></i> Drop image to select it for upload</h1>
</div>
</div>
</div>
<label class="label">Photo Attachment</label>
<div class="file has-name is-fullwidth">
<label class="file-label">
<input class="file-input" type="file"
name="file"
id="file"
accept=".jpg,.jpeg,.jpe,.png">
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
<span class="file-label">
Choose a file…
</span>
</span>
<span class="file-name" id="fileName">
Select a file
</span>
</label>
</div>
</div>
{{end}}
<div class="field has-text-centered">
<button type="submit"
name="intent"
@ -315,4 +369,60 @@ document.addEventListener('DOMContentLoaded', function() {
});
</script>
<!-- Script for photo upload on photo boards -->
{{if .Forum.PermitPhotos}}
<script type="text/javascript">
window.addEventListener("DOMContentLoaded", (event) => {
let $file = document.querySelector("#file"),
$fileName = document.querySelector("#fileName"),
$dropArea = document.querySelector("#drop-modal"),
$photoIntent = document.querySelector("#photoIntent"),
$body = document.querySelector("body");
// Common handler for file selection (file input or drag/drop)
let onFile = (file) => {
$photoIntent.value = "upload";
$fileName.innerHTML = file.name;
};
// Set up drag/drop file upload events.
$body.addEventListener("dragenter", function(e) {
e.preventDefault();
e.stopPropagation();
$dropArea.classList.add("is-active");
});
$body.addEventListener("dragover", function(e) {
e.preventDefault();
e.stopPropagation();
$dropArea.classList.add("is-active");
});
$body.addEventListener("dragleave", function(e) {
e.preventDefault();
e.stopPropagation();
$dropArea.classList.remove("is-active");
});
$body.addEventListener("drop", function(e) {
e.preventDefault();
e.stopPropagation();
$dropArea.classList.remove("is-active");
// Grab the file.
let dt = e.dataTransfer;
let file = dt.files[0];
// Set the file on the input field too.
$file.files = dt.files;
onFile(file);
});
// File input handler.
$file.addEventListener("change", function() {
let file = this.files[0];
onFile(file);
});
});
</script>
{{end}}<!-- .Forum.PermitPhotos -->
{{end}}

View File

@ -201,7 +201,7 @@
<div class="card nonshy-collapsible-mobile">
<header class="card-header has-background-link-light">
<p class="card-header-title">
Search Filters <span class="tag is-success ml-2">NEW</span>
Search Filters
</p>
<button class="card-header-icon" type="button">
<span class="icon">
@ -286,7 +286,6 @@
<a href="/photo/private" class="has-text-private">
<span class="icon"><i class="fa fa-lock"></i></span>
<span>Manage who can see <strong>my</strong> private photos</span>
<span class="tag is-success">NEW</span>
</a>
</div>
{{else if and (not .IsSiteGallery) (not .IsMyPrivateUnlockedFor)}}
@ -294,7 +293,6 @@
<a href="/photo/private/share?to={{.User.Username}}" class="has-text-private">
<span class="icon"><i class="fa fa-unlock"></i></span>
<span>Grant <strong>{{.User.Username}}</strong> access to see <strong>my</strong> private photos</span>
<span class="tag is-success">NEW</span>
</a>
</div>
{{else if and (not .IsSiteGallery) .IsMyPrivateUnlockedFor}}
@ -302,7 +300,6 @@
<span class="icon"><i class="fa fa-unlock has-text-private"></i></span>
<span>You had granted <strong>{{.User.Username}}</strong> access to see <strong>your</strong> private photos.</span>
<a href="/photo/private">Manage that here.</a>
<span class="tag is-success">NEW</span>
</div>
{{end}}

View File

@ -367,7 +367,6 @@
</form>
<!-- image cropper -->
<!-- <script src="/static/js/jquery-3.6.0.min.js"></script> -->
<link rel="stylesheet" href="/static/js/croppr/croppr.min.css">
<script src="/static/js/croppr/croppr.js"></script>
@ -388,7 +387,7 @@
$hiddenPreview = document.querySelector("#imagePreview"),
$previewBox = document.querySelector("#previewBox"),
$cropField = document.querySelector("#cropCoords"),
$dropArea = document.querySelector("#drop-modal")
$dropArea = document.querySelector("#drop-modal"),
$body = document.querySelector("body");
// Common handler for file selection, either via input