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, ...
|
||||
* `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.
|
|
@ -92,6 +92,7 @@ var (
|
|||
"Rules and Announcements",
|
||||
"Nudists",
|
||||
"Exhibitionists",
|
||||
"Photo Boards",
|
||||
"Anything Goes",
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
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
|
||||
|
||||
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)
|
||||
|
|
|
@ -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
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(&Notification{})
|
||||
DB.AutoMigrate(&Subscription{})
|
||||
DB.AutoMigrate(&CommentPhoto{})
|
||||
}
|
||||
|
|
|
@ -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))))
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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}}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue
Block a user