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:
parent
6dcb0c66e8
commit
47f898561c
22
README.md
22
README.md
|
@ -112,6 +112,28 @@ the web app by using the admin controls on their profile page.
|
||||||
templates, issue redirects, error pages, ...
|
templates, issue redirects, error pages, ...
|
||||||
* `pkg/utility`: miscellaneous useful functions for the app.
|
* `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
|
## License
|
||||||
|
|
||||||
GPLv3.
|
GPLv3.
|
|
@ -92,6 +92,7 @@ var (
|
||||||
"Rules and Announcements",
|
"Rules and Announcements",
|
||||||
"Nudists",
|
"Nudists",
|
||||||
"Exhibitionists",
|
"Exhibitionists",
|
||||||
|
"Photo Boards",
|
||||||
"Anything Goes",
|
"Anything Goes",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Current loaded settings.json
|
// Current loaded settings.json
|
||||||
|
@ -17,6 +18,7 @@ var Current = DefaultVariable()
|
||||||
type Variable struct {
|
type Variable struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
AdminEmail string
|
AdminEmail string
|
||||||
|
CronAPIKey string
|
||||||
Mail Mail
|
Mail Mail
|
||||||
Redis Redis
|
Redis Redis
|
||||||
Database Database
|
Database Database
|
||||||
|
@ -41,6 +43,7 @@ func DefaultVariable() Variable {
|
||||||
SQLite: "database.sqlite",
|
SQLite: "database.sqlite",
|
||||||
Postgres: "host=localhost user=nonshy password=nonshy dbname=nonshy port=5679 sslmode=disable TimeZone=America/Los_Angeles",
|
Postgres: "host=localhost user=nonshy password=nonshy dbname=nonshy port=5679 sslmode=disable TimeZone=America/Los_Angeles",
|
||||||
},
|
},
|
||||||
|
CronAPIKey: uuid.New().String(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
93
pkg/controller/api/orphaned_comment_photos.go
Normal file
93
pkg/controller/api/orphaned_comment_photos.go
Normal 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),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,13 +1,18 @@
|
||||||
package forum
|
package forum
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/markdown"
|
"code.nonshy.com/nonshy/website/pkg/markdown"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"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/session"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"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
|
quoteCommentID = r.FormValue("quote") // add reply to thread while quoting a comment
|
||||||
editCommentID = r.FormValue("edit") // edit your comment
|
editCommentID = r.FormValue("edit") // edit your comment
|
||||||
intent = r.FormValue("intent") // preview or submit
|
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
|
title = r.FormValue("title") // for new forum post only
|
||||||
message = r.PostFormValue("message") // comment body
|
message = r.PostFormValue("message") // comment body
|
||||||
isPinned = r.PostFormValue("pinned") == "true" // owners or admins only
|
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
|
// thread, we show and accept the thread settings to be updated as
|
||||||
// well (pinned, explicit, noreply)
|
// well (pinned, explicit, noreply)
|
||||||
isOriginalComment bool
|
isOriginalComment bool
|
||||||
|
|
||||||
|
// Attached photo object.
|
||||||
|
commentPhoto *models.CommentPhoto
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get the current user.
|
// 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?
|
// Are we pre-filling the message with a quotation of an existing comment?
|
||||||
if len(quoteCommentID) > 0 {
|
if len(quoteCommentID) > 0 {
|
||||||
if i, err := strconv.Atoi(quoteCommentID); err == nil {
|
if i, err := strconv.Atoi(quoteCommentID); err == nil {
|
||||||
|
@ -95,6 +118,11 @@ func NewPost() http.HandlerFunc {
|
||||||
message = comment.Message
|
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?
|
// Is this the OG thread of the post?
|
||||||
if thread.CommentID == comment.ID {
|
if thread.CommentID == comment.ID {
|
||||||
isOriginalComment = true
|
isOriginalComment = true
|
||||||
|
@ -130,8 +158,89 @@ func NewPost() http.HandlerFunc {
|
||||||
|
|
||||||
// Submitting the form.
|
// Submitting the form.
|
||||||
if r.Method == http.MethodPost {
|
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.
|
// Default intent is preview unless told to submit.
|
||||||
if intent == "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?
|
// Are we modifying an existing comment?
|
||||||
if comment != nil {
|
if comment != nil {
|
||||||
comment.Message = message
|
comment.Message = message
|
||||||
|
@ -157,11 +266,19 @@ func NewPost() http.HandlerFunc {
|
||||||
|
|
||||||
// Are we replying to an existing thread?
|
// Are we replying to an existing thread?
|
||||||
if thread != nil {
|
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)
|
session.FlashError(w, r, "Couldn't add reply to thread: %s", err)
|
||||||
} else {
|
} else {
|
||||||
session.Flash(w, r, "Reply added to the thread!")
|
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.
|
// Notify watchers about this new post.
|
||||||
for _, userID := range models.GetSubscribers("threads", thread.ID) {
|
for _, userID := range models.GetSubscribers("threads", thread.ID) {
|
||||||
if userID == currentUser.ID {
|
if userID == currentUser.ID {
|
||||||
|
@ -205,6 +322,14 @@ func NewPost() http.HandlerFunc {
|
||||||
} else {
|
} else {
|
||||||
session.Flash(w, r, "Thread created!")
|
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.
|
// Subscribe the current user to responses on this thread.
|
||||||
if _, err := models.SubscribeTo(currentUser, "threads", thread.ID); err != nil {
|
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)
|
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,
|
"IsPinned": isPinned,
|
||||||
"IsExplicit": isExplicit,
|
"IsExplicit": isExplicit,
|
||||||
"IsNoReply": isNoReply,
|
"IsNoReply": isNoReply,
|
||||||
|
|
||||||
|
// Attached photo.
|
||||||
|
"CommentPhoto": commentPhoto,
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
|
@ -81,6 +81,12 @@ func Thread() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
commentLikeMap := models.MapLikes(currentUser, "comments", commentIDs)
|
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?
|
// Is the current user subscribed to notifications on this thread?
|
||||||
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
|
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
|
||||||
|
|
||||||
|
@ -89,6 +95,7 @@ func Thread() http.HandlerFunc {
|
||||||
"Thread": thread,
|
"Thread": thread,
|
||||||
"Comments": comments,
|
"Comments": comments,
|
||||||
"LikeMap": commentLikeMap,
|
"LikeMap": commentLikeMap,
|
||||||
|
"PhotoMap": photos,
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
"IsSubscribed": isSubscribed,
|
"IsSubscribed": isSubscribed,
|
||||||
}
|
}
|
||||||
|
|
112
pkg/models/comment_photo.go
Normal file
112
pkg/models/comment_photo.go
Normal 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
|
||||||
|
}
|
|
@ -23,4 +23,5 @@ func AutoMigrate() {
|
||||||
DB.AutoMigrate(&Like{})
|
DB.AutoMigrate(&Like{})
|
||||||
DB.AutoMigrate(&Notification{})
|
DB.AutoMigrate(&Notification{})
|
||||||
DB.AutoMigrate(&Subscription{})
|
DB.AutoMigrate(&Subscription{})
|
||||||
|
DB.AutoMigrate(&CommentPhoto{})
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,6 +81,7 @@ func New() http.Handler {
|
||||||
mux.HandleFunc("/v1/users/me", api.LoginOK())
|
mux.HandleFunc("/v1/users/me", api.LoginOK())
|
||||||
mux.Handle("/v1/likes", middleware.LoginRequired(api.Likes()))
|
mux.Handle("/v1/likes", middleware.LoginRequired(api.Likes()))
|
||||||
mux.Handle("/v1/notifications/read", middleware.LoginRequired(api.ReadNotification()))
|
mux.Handle("/v1/notifications/read", middleware.LoginRequired(api.ReadNotification()))
|
||||||
|
mux.Handle("/v1/comment-photos/remove-orphaned", api.RemoveOrphanedCommentPhotos())
|
||||||
|
|
||||||
// Static files.
|
// Static files.
|
||||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(config.StaticPath))))
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(config.StaticPath))))
|
||||||
|
|
|
@ -97,7 +97,6 @@
|
||||||
<a href="/photo/private">
|
<a href="/photo/private">
|
||||||
<span class="icon"><i class="fa fa-eye"></i></span>
|
<span class="icon"><i class="fa fa-eye"></i></span>
|
||||||
Manage Private Photos
|
Manage Private Photos
|
||||||
<span class="tag is-success ml-1">NEW!</span>
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
|
|
@ -89,6 +89,13 @@
|
||||||
<span>Privileged</span>
|
<span>Privileged</span>
|
||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -54,7 +54,7 @@
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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}}
|
{{InputCSRF}}
|
||||||
|
|
||||||
{{if not .Thread}}
|
{{if not .Thread}}
|
||||||
|
@ -74,13 +74,65 @@
|
||||||
<textarea class="textarea" cols="80" rows="8"
|
<textarea class="textarea" cols="80" rows="8"
|
||||||
name="message"
|
name="message"
|
||||||
id="message"
|
id="message"
|
||||||
required
|
{{if not .Forum.PermitPhotos}}required{{end}}
|
||||||
placeholder="Message">{{.Message}}</textarea>
|
placeholder="Message">{{.Message}}</textarea>
|
||||||
<p class="help">
|
<p class="help">
|
||||||
Markdown formatting supported.
|
Markdown formatting supported.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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}}
|
{{if or (not .Thread) .EditThreadSettings}}
|
||||||
<div class="field block">
|
<div class="field block">
|
||||||
{{if or .CurrentUser.IsAdmin (and .Forum (eq .Forum.OwnerID .CurrentUser.ID))}}
|
{{if or .CurrentUser.IsAdmin (and .Forum (eq .Forum.OwnerID .CurrentUser.ID))}}
|
||||||
|
@ -144,5 +196,85 @@
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
|
@ -149,6 +149,22 @@
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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">
|
<hr class="has-background-grey mb-2">
|
||||||
|
|
||||||
<div class="columns is-mobile is-multiline is-size-7 mb-0">
|
<div class="columns is-mobile is-multiline is-size-7 mb-0">
|
||||||
|
@ -243,7 +259,7 @@
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card-content">
|
<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}}
|
{{InputCSRF}}
|
||||||
|
|
||||||
<div class="field block">
|
<div class="field block">
|
||||||
|
@ -251,13 +267,51 @@
|
||||||
<textarea class="textarea" cols="80" rows="6"
|
<textarea class="textarea" cols="80" rows="6"
|
||||||
name="message"
|
name="message"
|
||||||
id="message"
|
id="message"
|
||||||
required
|
{{if not .Forum.PermitPhotos}}required{{end}}
|
||||||
placeholder="Message"></textarea>
|
placeholder="Message"></textarea>
|
||||||
<p class="help">
|
<p class="help">
|
||||||
Markdown formatting supported.
|
Markdown formatting supported.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div class="field has-text-centered">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
name="intent"
|
name="intent"
|
||||||
|
@ -315,4 +369,60 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
});
|
});
|
||||||
</script>
|
</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}}
|
{{end}}
|
|
@ -201,7 +201,7 @@
|
||||||
<div class="card nonshy-collapsible-mobile">
|
<div class="card nonshy-collapsible-mobile">
|
||||||
<header class="card-header has-background-link-light">
|
<header class="card-header has-background-link-light">
|
||||||
<p class="card-header-title">
|
<p class="card-header-title">
|
||||||
Search Filters <span class="tag is-success ml-2">NEW</span>
|
Search Filters
|
||||||
</p>
|
</p>
|
||||||
<button class="card-header-icon" type="button">
|
<button class="card-header-icon" type="button">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
|
@ -286,7 +286,6 @@
|
||||||
<a href="/photo/private" class="has-text-private">
|
<a href="/photo/private" class="has-text-private">
|
||||||
<span class="icon"><i class="fa fa-lock"></i></span>
|
<span class="icon"><i class="fa fa-lock"></i></span>
|
||||||
<span>Manage who can see <strong>my</strong> private photos</span>
|
<span>Manage who can see <strong>my</strong> private photos</span>
|
||||||
<span class="tag is-success">NEW</span>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{{else if and (not .IsSiteGallery) (not .IsMyPrivateUnlockedFor)}}
|
{{else if and (not .IsSiteGallery) (not .IsMyPrivateUnlockedFor)}}
|
||||||
|
@ -294,7 +293,6 @@
|
||||||
<a href="/photo/private/share?to={{.User.Username}}" class="has-text-private">
|
<a href="/photo/private/share?to={{.User.Username}}" class="has-text-private">
|
||||||
<span class="icon"><i class="fa fa-unlock"></i></span>
|
<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>Grant <strong>{{.User.Username}}</strong> access to see <strong>my</strong> private photos</span>
|
||||||
<span class="tag is-success">NEW</span>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{{else if and (not .IsSiteGallery) .IsMyPrivateUnlockedFor}}
|
{{else if and (not .IsSiteGallery) .IsMyPrivateUnlockedFor}}
|
||||||
|
@ -302,7 +300,6 @@
|
||||||
<span class="icon"><i class="fa fa-unlock has-text-private"></i></span>
|
<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>
|
<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>
|
<a href="/photo/private">Manage that here.</a>
|
||||||
<span class="tag is-success">NEW</span>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|
|
@ -367,7 +367,6 @@
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- image cropper -->
|
<!-- image cropper -->
|
||||||
<!-- <script src="/static/js/jquery-3.6.0.min.js"></script> -->
|
|
||||||
<link rel="stylesheet" href="/static/js/croppr/croppr.min.css">
|
<link rel="stylesheet" href="/static/js/croppr/croppr.min.css">
|
||||||
<script src="/static/js/croppr/croppr.js"></script>
|
<script src="/static/js/croppr/croppr.js"></script>
|
||||||
|
|
||||||
|
@ -388,7 +387,7 @@
|
||||||
$hiddenPreview = document.querySelector("#imagePreview"),
|
$hiddenPreview = document.querySelector("#imagePreview"),
|
||||||
$previewBox = document.querySelector("#previewBox"),
|
$previewBox = document.querySelector("#previewBox"),
|
||||||
$cropField = document.querySelector("#cropCoords"),
|
$cropField = document.querySelector("#cropCoords"),
|
||||||
$dropArea = document.querySelector("#drop-modal")
|
$dropArea = document.querySelector("#drop-modal"),
|
||||||
$body = document.querySelector("body");
|
$body = document.querySelector("body");
|
||||||
|
|
||||||
// Common handler for file selection, either via input
|
// Common handler for file selection, either via input
|
||||||
|
|
Loading…
Reference in New Issue
Block a user