User Photo Gallery & Management
* Add the user photo gallery for profile pages. Paginated, grid or full (blog style) view options. In grid view clicking a photo opens a large modal to see it; full view already shows large photos. * Edit page: can also re-crop and set an existing pic to be your profile pic. * Delete page: remove photos from the DB and hard drive. * Photos are cleaned up from disk when not needed, e.g. during a re-crop the old cropped photo is removed before the new one replaces it. * Fixed bug with cropping pictures.
This commit is contained in:
parent
b72973e741
commit
4533c15747
|
@ -8,7 +8,7 @@ import (
|
||||||
|
|
||||||
// Branding
|
// Branding
|
||||||
const (
|
const (
|
||||||
Title = "gosocial"
|
Title = "nonshy"
|
||||||
Subtitle = "A purpose built social networking app."
|
Subtitle = "A purpose built social networking app."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ const (
|
||||||
// Web path where photos are kept. Photos in DB store only their filenames, this
|
// Web path where photos are kept. Photos in DB store only their filenames, this
|
||||||
// is the base URL that goes in front. TODO: support setting a CDN URL prefix.
|
// is the base URL that goes in front. TODO: support setting a CDN URL prefix.
|
||||||
JpegQuality = 90
|
JpegQuality = 90
|
||||||
PhotoWebPath = "/static/photos/"
|
PhotoWebPath = "/static/photos"
|
||||||
PhotoDiskPath = "./web/static/photos"
|
PhotoDiskPath = "./web/static/photos"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
209
pkg/controller/photo/edit_delete.go
Normal file
209
pkg/controller/photo/edit_delete.go
Normal file
|
@ -0,0 +1,209 @@
|
||||||
|
package photo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||||
|
pphoto "git.kirsle.net/apps/gosocial/pkg/photo"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Edit controller (/photo/edit?id=N) to change properties about your picture.
|
||||||
|
func Edit() http.HandlerFunc {
|
||||||
|
// Reuse the upload page but with an EditPhoto variable.
|
||||||
|
tmpl := templates.Must("photo/upload.html")
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Query params.
|
||||||
|
photoID, err := strconv.Atoi(r.FormValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Photo 'id' parameter required.")
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find this photo by ID.
|
||||||
|
photo, err := models.GetPhoto(uint64(photoID))
|
||||||
|
if err != nil {
|
||||||
|
templates.NotFoundPage(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the current user.
|
||||||
|
currentUser, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do we have permission for this photo?
|
||||||
|
if photo.UserID != currentUser.ID && !currentUser.IsAdmin {
|
||||||
|
templates.ForbiddenPage(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Are we saving the changes?
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
var (
|
||||||
|
caption = r.FormValue("caption")
|
||||||
|
isExplicit = r.FormValue("explicit") == "true"
|
||||||
|
isGallery = r.FormValue("gallery") == "true"
|
||||||
|
visibility = r.FormValue("visibility")
|
||||||
|
|
||||||
|
// Profile pic fields
|
||||||
|
setProfilePic = r.FormValue("intent") == "profile-pic"
|
||||||
|
crop = pphoto.ParseCropCoords(r.FormValue("crop"))
|
||||||
|
)
|
||||||
|
|
||||||
|
photo.Caption = caption
|
||||||
|
photo.Explicit = isExplicit
|
||||||
|
photo.Gallery = isGallery
|
||||||
|
photo.Visibility = models.PhotoVisibility(visibility)
|
||||||
|
|
||||||
|
// Are we cropping ourselves a new profile pic?
|
||||||
|
log.Error("Profile pic? %+v and crop is: %+v", setProfilePic, crop)
|
||||||
|
if setProfilePic && crop != nil && len(crop) >= 4 {
|
||||||
|
cropFilename, err := pphoto.ReCrop(photo.Filename, crop[0], crop[1], crop[2], crop[3])
|
||||||
|
log.Error("ReCrop got: %s, %s", cropFilename, err)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't re-crop for profile picture: %s", err)
|
||||||
|
} else {
|
||||||
|
// If there was an old profile pic, remove it from disk.
|
||||||
|
if photo.CroppedFilename != "" {
|
||||||
|
pphoto.Delete(photo.CroppedFilename)
|
||||||
|
}
|
||||||
|
photo.CroppedFilename = cropFilename
|
||||||
|
log.Warn("HERE WE SET (%s) ON PHOTO (%+v)", cropFilename, photo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Error("SAVING PHOTO: %+v", photo)
|
||||||
|
|
||||||
|
if err := photo.Save(); err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't save photo: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set their profile pic to this one.
|
||||||
|
currentUser.ProfilePhoto = *photo
|
||||||
|
log.Error("Set user ProfilePhotoID=%d", photo.ID)
|
||||||
|
if err := currentUser.Save(); err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't save user: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flash success.
|
||||||
|
session.Flash(w, r, "Photo settings updated!")
|
||||||
|
|
||||||
|
// Whose photo gallery to redirect to? if admin editing a user's photo,
|
||||||
|
// go back to the owner's gallery instead of our own.
|
||||||
|
if photo.UserID != currentUser.ID {
|
||||||
|
if owner, err := models.GetUser(photo.UserID); err == nil {
|
||||||
|
templates.Redirect(w, "/photo/u/"+owner.Username)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the user to their gallery.
|
||||||
|
templates.Redirect(w, "/photo/u/"+currentUser.Username)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var vars = map[string]interface{}{
|
||||||
|
"EditPhoto": photo,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete controller (/photo/Delete?id=N) to change properties about your picture.
|
||||||
|
func Delete() http.HandlerFunc {
|
||||||
|
// Reuse the upload page but with an EditPhoto variable.
|
||||||
|
tmpl := templates.Must("photo/delete.html")
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Query params.
|
||||||
|
photoID, err := strconv.Atoi(r.FormValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Photo 'id' parameter required.")
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find this photo by ID.
|
||||||
|
photo, err := models.GetPhoto(uint64(photoID))
|
||||||
|
if err != nil {
|
||||||
|
templates.NotFoundPage(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the current user.
|
||||||
|
currentUser, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do we have permission for this photo?
|
||||||
|
if photo.UserID != currentUser.ID && !currentUser.IsAdmin {
|
||||||
|
templates.ForbiddenPage(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm deletion?
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
confirm := r.PostFormValue("confirm") == "true"
|
||||||
|
if !confirm {
|
||||||
|
session.FlashError(w, r, "Confirm you want to delete this photo.")
|
||||||
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the images from disk.
|
||||||
|
for _, filename := range []string{
|
||||||
|
photo.Filename,
|
||||||
|
photo.CroppedFilename,
|
||||||
|
} {
|
||||||
|
if len(filename) > 0 {
|
||||||
|
if err := pphoto.Delete(filename); err != nil {
|
||||||
|
log.Error("Delete Photo: couldn't remove file from disk: %s: %s", filename, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := photo.Delete(); err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't delete photo: %s", err)
|
||||||
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Flash(w, r, "Photo deleted!")
|
||||||
|
|
||||||
|
// Whose photo gallery to redirect to? if admin editing a user's photo,
|
||||||
|
// go back to the owner's gallery instead of our own.
|
||||||
|
if photo.UserID != currentUser.ID {
|
||||||
|
if owner, err := models.GetUser(photo.UserID); err == nil {
|
||||||
|
templates.Redirect(w, "/photo/u/"+owner.Username)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the user to their gallery.
|
||||||
|
templates.Redirect(w, "/photo/u/"+currentUser.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
var vars = map[string]interface{}{
|
||||||
|
"Photo": photo,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -6,8 +6,6 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||||
"git.kirsle.net/apps/gosocial/pkg/models"
|
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||||
|
@ -59,18 +57,8 @@ func Upload() http.HandlerFunc {
|
||||||
|
|
||||||
// Parse and validate crop coordinates.
|
// Parse and validate crop coordinates.
|
||||||
var crop []int
|
var crop []int
|
||||||
if len(cropCoords) > 0 {
|
if vars["NeedsCrop"] == true {
|
||||||
aints := strings.Split(cropCoords, ",")
|
crop = photo.ParseCropCoords(cropCoords)
|
||||||
if len(aints) >= 4 {
|
|
||||||
crop = []int{}
|
|
||||||
for i, aint := range aints {
|
|
||||||
if number, err := strconv.Atoi(strings.TrimSpace(aint)); err == nil {
|
|
||||||
crop = append(crop, number)
|
|
||||||
} else {
|
|
||||||
log.Error("Failure to parse crop coordinates ('%s') at number %d: %s", cropCoords, i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Error("parsed crop coords: %+v", crop)
|
log.Error("parsed crop coords: %+v", crop)
|
||||||
|
@ -127,12 +115,12 @@ func Upload() http.HandlerFunc {
|
||||||
// Are we uploading a profile pic? If so, set the user's pic now.
|
// Are we uploading a profile pic? If so, set the user's pic now.
|
||||||
if vars["Intent"] == "profile_pic" {
|
if vars["Intent"] == "profile_pic" {
|
||||||
log.Info("User %s is setting their profile picture", user.Username)
|
log.Info("User %s is setting their profile picture", user.Username)
|
||||||
user.ProfilePhotoID = p.ID
|
user.ProfilePhoto = *p
|
||||||
user.Save()
|
user.Save()
|
||||||
}
|
}
|
||||||
|
|
||||||
session.Flash(w, r, "Your photo has been uploaded successfully.")
|
session.Flash(w, r, "Your photo has been uploaded successfully.")
|
||||||
templates.Redirect(w, r.URL.Path)
|
templates.Redirect(w, "/photo/u/"+user.Username)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
77
pkg/controller/photo/user_gallery.go
Normal file
77
pkg/controller/photo/user_gallery.go
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
package photo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
var UserPhotosRegexp = regexp.MustCompile(`^/photo/u/([^@]+?)$`)
|
||||||
|
|
||||||
|
// UserPhotos controller (/photo/u/:username) to view a user's gallery or manage if it's yourself.
|
||||||
|
func UserPhotos() http.HandlerFunc {
|
||||||
|
tmpl := templates.Must("photo/user_photos.html")
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Query params.
|
||||||
|
var (
|
||||||
|
viewStyle = r.FormValue("view") // cards (default), full
|
||||||
|
)
|
||||||
|
if viewStyle != "full" {
|
||||||
|
viewStyle = "cards"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the username out of the URL parameters.
|
||||||
|
var username string
|
||||||
|
m := UserPhotosRegexp.FindStringSubmatch(r.URL.Path)
|
||||||
|
if m != nil {
|
||||||
|
username = m[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find this user.
|
||||||
|
user, err := models.FindUser(username)
|
||||||
|
if err != nil {
|
||||||
|
templates.NotFoundPage(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the current user in case they are viewing their own page.
|
||||||
|
currentUser, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
||||||
|
}
|
||||||
|
var isOwnPhotos = currentUser.ID == user.ID
|
||||||
|
|
||||||
|
// What set of visibilities to query?
|
||||||
|
visibility := []models.PhotoVisibility{models.PhotoPublic}
|
||||||
|
if isOwnPhotos || currentUser.IsAdmin {
|
||||||
|
visibility = append(visibility, models.PhotoFriends, models.PhotoPrivate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the page of photos.
|
||||||
|
pager := &models.Pagination{
|
||||||
|
Page: 1,
|
||||||
|
PerPage: 8,
|
||||||
|
Sort: "created_at desc",
|
||||||
|
}
|
||||||
|
pager.ParsePage(r)
|
||||||
|
log.Error("Pager: %+v", pager)
|
||||||
|
photos, err := models.PaginateUserPhotos(user.ID, visibility, pager)
|
||||||
|
|
||||||
|
var vars = map[string]interface{}{
|
||||||
|
"IsOwnPhotos": currentUser.ID == user.ID,
|
||||||
|
"User": user,
|
||||||
|
"Photos": photos,
|
||||||
|
"Pager": pager,
|
||||||
|
"ViewStyle": viewStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
76
pkg/models/pagination.go
Normal file
76
pkg/models/pagination.go
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pagination result object.
|
||||||
|
type Pagination struct {
|
||||||
|
Page int
|
||||||
|
PerPage int
|
||||||
|
Pages int
|
||||||
|
Total int64
|
||||||
|
Sort string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page for Iter.
|
||||||
|
type Page struct {
|
||||||
|
Page int
|
||||||
|
IsCurrent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the page from form or query parameters.
|
||||||
|
func (p *Pagination) ParsePage(r *http.Request) {
|
||||||
|
raw := r.FormValue("page")
|
||||||
|
a, err := strconv.Atoi(raw)
|
||||||
|
log.Debug("ParsePage: %s %d err=%s", raw, a, err)
|
||||||
|
if err == nil {
|
||||||
|
if a < 0 {
|
||||||
|
a = 1
|
||||||
|
}
|
||||||
|
p.Page = a
|
||||||
|
log.Warn("set page1=%+v =XXXXX%d", p, a)
|
||||||
|
}
|
||||||
|
log.Warn("set page=%+v", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iter the pages, for templates.
|
||||||
|
func (p *Pagination) Iter() []Page {
|
||||||
|
var pages = []Page{}
|
||||||
|
for i := 1; i <= p.Pages; i++ {
|
||||||
|
pages = append(pages, Page{
|
||||||
|
Page: i,
|
||||||
|
IsCurrent: i == p.Page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pagination) GetOffset() int {
|
||||||
|
return (p.Page - 1) * p.PerPage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pagination) HasNext() bool {
|
||||||
|
return p.Page < p.Pages
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pagination) HasPrevious() bool {
|
||||||
|
return p.Page > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pagination) Next() int {
|
||||||
|
if p.Page >= p.Pages {
|
||||||
|
return p.Pages
|
||||||
|
}
|
||||||
|
return p.Page + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pagination) Previous() int {
|
||||||
|
if p.Page > 1 {
|
||||||
|
return p.Page - 1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -49,3 +50,47 @@ func CreatePhoto(tmpl Photo) (*Photo, error) {
|
||||||
result := DB.Create(p)
|
result := DB.Create(p)
|
||||||
return p, result.Error
|
return p, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPhoto by ID.
|
||||||
|
func GetPhoto(id uint64) (*Photo, error) {
|
||||||
|
p := &Photo{}
|
||||||
|
result := DB.First(&p, id)
|
||||||
|
return p, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
PaginateUserPhotos gets a page of photos belonging to a user ID.
|
||||||
|
*/
|
||||||
|
func PaginateUserPhotos(userID uint64, visibility []PhotoVisibility, pager *Pagination) ([]*Photo, error) {
|
||||||
|
var p = []*Photo{}
|
||||||
|
|
||||||
|
query := DB.Where(
|
||||||
|
"user_id = ? AND visibility IN ?",
|
||||||
|
userID,
|
||||||
|
visibility,
|
||||||
|
).Order(
|
||||||
|
pager.Sort,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get the total count.
|
||||||
|
query.Model(&Photo{}).Count(&pager.Total)
|
||||||
|
pager.Pages = int(math.Ceil(float64(pager.Total) / float64(pager.PerPage)))
|
||||||
|
|
||||||
|
result := query.Offset(
|
||||||
|
pager.GetOffset(),
|
||||||
|
).Limit(pager.PerPage).Find(&p)
|
||||||
|
|
||||||
|
return p, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save photo.
|
||||||
|
func (p *Photo) Save() error {
|
||||||
|
result := DB.Save(p)
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete photo.
|
||||||
|
func (p *Photo) Delete() error {
|
||||||
|
result := DB.Delete(p)
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
|
@ -6,7 +6,6 @@ import "time"
|
||||||
type ProfileField struct {
|
type ProfileField struct {
|
||||||
ID uint64 `gorm:"primaryKey"`
|
ID uint64 `gorm:"primaryKey"`
|
||||||
UserID uint64 `gorm:"index"`
|
UserID uint64 `gorm:"index"`
|
||||||
User User
|
|
||||||
Name string `gorm:"index"`
|
Name string `gorm:"index"`
|
||||||
Value string `gorm:"index"`
|
Value string `gorm:"index"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -8,7 +10,7 @@ import (
|
||||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// User account table.
|
// User account table.
|
||||||
|
@ -31,7 +33,12 @@ type User struct {
|
||||||
// Relational tables.
|
// Relational tables.
|
||||||
ProfileField []ProfileField
|
ProfileField []ProfileField
|
||||||
ProfilePhotoID uint64
|
ProfilePhotoID uint64
|
||||||
ProfilePhoto Photo
|
ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload related tables for the user (classmethod).
|
||||||
|
func (u *User) Preload() *gorm.DB {
|
||||||
|
return DB.Preload("ProfileField").Preload("ProfilePhoto")
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserStatus options.
|
// UserStatus options.
|
||||||
|
@ -68,7 +75,7 @@ func CreateUser(username, email, password string) (*User, error) {
|
||||||
// GetUser by ID.
|
// GetUser by ID.
|
||||||
func GetUser(userId uint64) (*User, error) {
|
func GetUser(userId uint64) (*User, error) {
|
||||||
user := &User{}
|
user := &User{}
|
||||||
result := DB.Preload(clause.Associations).First(&user, userId)
|
result := user.Preload().First(&user, userId)
|
||||||
return user, result.Error
|
return user, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,10 +87,10 @@ func FindUser(username string) (*User, error) {
|
||||||
|
|
||||||
u := &User{}
|
u := &User{}
|
||||||
if strings.ContainsRune(username, '@') {
|
if strings.ContainsRune(username, '@') {
|
||||||
result := DB.Preload(clause.Associations).Where("email = ?", username).Limit(1).First(u)
|
result := u.Preload().Where("email = ?", username).Limit(1).First(u)
|
||||||
return u, result.Error
|
return u, result.Error
|
||||||
}
|
}
|
||||||
result := DB.Preload(clause.Associations).Where("username = ?", username).Limit(1).First(u)
|
result := u.Preload().Where("username = ?", username).Limit(1).First(u)
|
||||||
return u, result.Error
|
return u, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -157,3 +164,14 @@ func (u *User) Save() error {
|
||||||
result := DB.Save(u)
|
result := DB.Save(u)
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Print user object as pretty JSON.
|
||||||
|
func (u *User) Print() string {
|
||||||
|
var (
|
||||||
|
buf bytes.Buffer
|
||||||
|
enc = json.NewEncoder(&buf)
|
||||||
|
)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
enc.Encode(u)
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
|
@ -39,6 +39,11 @@ func DiskPath(filename string) string {
|
||||||
return config.PhotoDiskPath + "/" + filename
|
return config.PhotoDiskPath + "/" + filename
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// URLPath returns the public HTTP path to a photo. May be relative like "/static/photos" or could be a full CDN.
|
||||||
|
func URLPath(filename string) string {
|
||||||
|
return config.PhotoWebPath + "/" + filename
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
EnsurePath makes sure the local './web/static/photos/' path is ready
|
EnsurePath makes sure the local './web/static/photos/' path is ready
|
||||||
to write an image to, taking into account path parameters in the
|
to write an image to, taking into account path parameters in the
|
||||||
|
|
|
@ -9,6 +9,9 @@ import (
|
||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||||
|
@ -62,23 +65,20 @@ func UploadPhoto(cfg UploadConfig) (string, string, error) {
|
||||||
|
|
||||||
// Read the config to get the image width.
|
// Read the config to get the image width.
|
||||||
reader.Seek(0, io.SeekStart)
|
reader.Seek(0, io.SeekStart)
|
||||||
var width, height int
|
var width, height = origImage.Bounds().Max.X, origImage.Bounds().Max.Y
|
||||||
if decoded, _, err := image.DecodeConfig(reader); err == nil {
|
|
||||||
width, height = decoded.Width, decoded.Height
|
|
||||||
} else {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the longest edge, if it's too large (over 1280px)
|
// Find the longest edge, if it's too large (over 1280px)
|
||||||
// cap it to the max and scale the other dimension proportionally.
|
// cap it to the max and scale the other dimension proportionally.
|
||||||
log.Debug("UploadPhoto: taking a %dx%d image to name it %s", width, height, filename)
|
log.Debug("UploadPhoto: taking a w=%d by h=%d image to name it %s", width, height, filename)
|
||||||
if width >= height {
|
if width >= height {
|
||||||
|
log.Debug("Its width(%d) is >= its height (%d)", width, height)
|
||||||
if width > config.MaxPhotoWidth {
|
if width > config.MaxPhotoWidth {
|
||||||
newWidth := config.MaxPhotoWidth
|
newWidth := config.MaxPhotoWidth
|
||||||
log.Debug("(%d / %d) * %d", width, height, newWidth)
|
log.Debug("\tnewWidth=%d", newWidth)
|
||||||
|
log.Debug("\tnewHeight=(%d / %d) * %d", width, height, newWidth)
|
||||||
height = int((float64(height) / float64(width)) * float64(newWidth))
|
height = int((float64(height) / float64(width)) * float64(newWidth))
|
||||||
width = newWidth
|
width = newWidth
|
||||||
log.Debug("Its longest is width, scale to %sx%s", width, height)
|
log.Debug("Its longest is width, scale to %dx%d", width, height)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if height > config.MaxPhotoWidth {
|
if height > config.MaxPhotoWidth {
|
||||||
|
@ -107,12 +107,7 @@ func UploadPhoto(cfg UploadConfig) (string, string, error) {
|
||||||
w = cfg.Crop[2]
|
w = cfg.Crop[2]
|
||||||
h = cfg.Crop[3]
|
h = cfg.Crop[3]
|
||||||
)
|
)
|
||||||
croppedImg := Crop(origImage, image.Rect(
|
croppedImg := Crop(origImage, x, y, w, h)
|
||||||
x,
|
|
||||||
y,
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
))
|
|
||||||
|
|
||||||
// Write that to disk, too.
|
// Write that to disk, too.
|
||||||
log.Debug("Writing cropped image to disk: %s", cropFilename)
|
log.Debug("Writing cropped image to disk: %s", cropFilename)
|
||||||
|
@ -133,19 +128,83 @@ func UploadPhoto(cfg UploadConfig) (string, string, error) {
|
||||||
// scaled := Scale(src, image.Rect(0, 0, 200, 200), draw.ApproxBiLinear)
|
// scaled := Scale(src, image.Rect(0, 0, 200, 200), draw.ApproxBiLinear)
|
||||||
func Scale(src image.Image, rect image.Rectangle, scale draw.Scaler) image.Image {
|
func Scale(src image.Image, rect image.Rectangle, scale draw.Scaler) image.Image {
|
||||||
dst := image.NewRGBA(rect)
|
dst := image.NewRGBA(rect)
|
||||||
scale.Scale(dst, rect, src, src.Bounds(), draw.Over, nil)
|
copyRect := image.Rect(
|
||||||
|
rect.Min.X,
|
||||||
|
rect.Min.Y,
|
||||||
|
rect.Min.X+rect.Max.X,
|
||||||
|
rect.Min.Y+rect.Max.Y,
|
||||||
|
)
|
||||||
|
scale.Scale(dst, copyRect, src, src.Bounds(), draw.Over, nil)
|
||||||
return dst
|
return dst
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crop an image, returning the new image. Example:
|
// Crop an image, returning the new image. Example:
|
||||||
//
|
//
|
||||||
// cropped := Crop()
|
// cropped := Crop()
|
||||||
func Crop(src image.Image, rect image.Rectangle) image.Image {
|
func Crop(src image.Image, x, y, w, h int) image.Image {
|
||||||
dst := image.NewRGBA(rect)
|
dst := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
draw.Copy(dst, image.Point{}, src, rect, draw.Over, nil)
|
srcrect := image.Rect(x, y, x+w, y+h)
|
||||||
|
draw.Copy(dst, image.ZP, src, srcrect, draw.Over, nil)
|
||||||
return dst
|
return dst
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReCrop an image, loading the original image from disk. Returns the newly created filename.
|
||||||
|
func ReCrop(filename string, x, y, w, h int) (string, error) {
|
||||||
|
var (
|
||||||
|
ext = filepath.Ext(filename)
|
||||||
|
cropFilename = NewFilename(ext)
|
||||||
|
)
|
||||||
|
|
||||||
|
fh, err := os.Open(DiskPath(filename))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the image.
|
||||||
|
var img image.Image
|
||||||
|
switch ext {
|
||||||
|
case ".jpg":
|
||||||
|
img, err = jpeg.Decode(fh)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
case ".png":
|
||||||
|
img, err = png.Decode(fh)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", errors.New("unsupported file type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crop it.
|
||||||
|
croppedImg := Crop(img, x, y, w, h)
|
||||||
|
|
||||||
|
// Write it.
|
||||||
|
err = ToDisk(cropFilename, ext, croppedImg)
|
||||||
|
return cropFilename, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCropCoords splits a string of x,y,w,h values into proper crop coordinates, or nil.
|
||||||
|
func ParseCropCoords(coords string) []int {
|
||||||
|
// Parse and validate crop coordinates.
|
||||||
|
var crop []int
|
||||||
|
if len(coords) > 0 {
|
||||||
|
aints := strings.Split(coords, ",")
|
||||||
|
if len(aints) >= 4 {
|
||||||
|
crop = []int{}
|
||||||
|
for i, aint := range aints {
|
||||||
|
if number, err := strconv.Atoi(strings.TrimSpace(aint)); err == nil {
|
||||||
|
crop = append(crop, number)
|
||||||
|
} else {
|
||||||
|
log.Error("Failure to parse crop coordinates ('%s') at number %d: %s", coords, i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crop
|
||||||
|
}
|
||||||
|
|
||||||
// ToDisk commits a photo image to disk in the right file format.
|
// ToDisk commits a photo image to disk in the right file format.
|
||||||
//
|
//
|
||||||
// Filename is like NewFilename() and it goes to e.g. "./web/static/photos/"
|
// Filename is like NewFilename() and it goes to e.g. "./web/static/photos/"
|
||||||
|
@ -171,3 +230,8 @@ func ToDisk(filename string, extension string, img image.Image) error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete a photo from disk.
|
||||||
|
func Delete(filename string) error {
|
||||||
|
return os.Remove(DiskPath(filename))
|
||||||
|
}
|
||||||
|
|
|
@ -26,6 +26,9 @@ func New() http.Handler {
|
||||||
mux.Handle("/settings", middleware.LoginRequired(account.Settings()))
|
mux.Handle("/settings", middleware.LoginRequired(account.Settings()))
|
||||||
mux.Handle("/u/", middleware.LoginRequired(account.Profile()))
|
mux.Handle("/u/", middleware.LoginRequired(account.Profile()))
|
||||||
mux.Handle("/photo/upload", middleware.LoginRequired(photo.Upload()))
|
mux.Handle("/photo/upload", middleware.LoginRequired(photo.Upload()))
|
||||||
|
mux.Handle("/photo/u/", middleware.LoginRequired(photo.UserPhotos()))
|
||||||
|
mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit()))
|
||||||
|
mux.Handle("/photo/delete", middleware.LoginRequired(photo.Delete()))
|
||||||
|
|
||||||
// JSON API endpoints.
|
// JSON API endpoints.
|
||||||
mux.HandleFunc("/v1/version", api.Version())
|
mux.HandleFunc("/v1/version", api.Version())
|
||||||
|
|
|
@ -9,6 +9,11 @@ var NotFoundPage = func() http.HandlerFunc {
|
||||||
return MakeErrorPage("Not Found", "The page you requested was not here.", http.StatusNotFound)
|
return MakeErrorPage("Not Found", "The page you requested was not here.", http.StatusNotFound)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// ForbiddenPage is an HTTP handler for 404 pages.
|
||||||
|
var ForbiddenPage = func() http.HandlerFunc {
|
||||||
|
return MakeErrorPage("Forbidden", "You do not have permission for this page.", http.StatusForbidden)
|
||||||
|
}()
|
||||||
|
|
||||||
func MakeErrorPage(header string, message string, statusCode int) http.HandlerFunc {
|
func MakeErrorPage(header string, message string, statusCode int) http.HandlerFunc {
|
||||||
tmpl := Must("errors/error.html")
|
tmpl := Must("errors/error.html")
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
@ -9,6 +9,7 @@ import (
|
||||||
|
|
||||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||||
"git.kirsle.net/apps/gosocial/pkg/markdown"
|
"git.kirsle.net/apps/gosocial/pkg/markdown"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/photo"
|
||||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||||
"git.kirsle.net/apps/gosocial/pkg/utility"
|
"git.kirsle.net/apps/gosocial/pkg/utility"
|
||||||
)
|
)
|
||||||
|
@ -21,6 +22,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
||||||
"ComputeAge": utility.Age,
|
"ComputeAge": utility.Age,
|
||||||
"Split": strings.Split,
|
"Split": strings.Split,
|
||||||
"ToMarkdown": ToMarkdown,
|
"ToMarkdown": ToMarkdown,
|
||||||
|
"PhotoURL": photo.URLPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,8 @@ func MergeVars(r *http.Request, m map[string]interface{}) {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m["Request"] = r
|
||||||
}
|
}
|
||||||
|
|
||||||
// MergeUserVars mixes in global template variables: LoggedIn and CurrentUser. The http.Request is optional.
|
// MergeUserVars mixes in global template variables: LoggedIn and CurrentUser. The http.Request is optional.
|
||||||
|
|
|
@ -19,3 +19,10 @@
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Photo modals in addition to Bulma .modal */
|
||||||
|
.photo-modal {
|
||||||
|
width: auto !important;
|
||||||
|
max-width: fit-content;
|
||||||
|
max-height: fit-content;
|
||||||
|
}
|
|
@ -19,4 +19,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
// Common event handlers for bulma modals.
|
||||||
|
(document.querySelectorAll(".modal-background, .modal-close, .photo-modal") || []).forEach(node => {
|
||||||
|
const target = node.closest(".modal");
|
||||||
|
node.addEventListener("click", () => {
|
||||||
|
target.classList.remove("is-active");
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
|
@ -19,11 +19,42 @@
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<ul class="menu-list">
|
<ul class="menu-list">
|
||||||
<li><a href="/u/{{.CurrentUser.Username}}">My Profile</a></li>
|
<li>
|
||||||
<li><a href="/photo/upload">Upload Photos</a></li>
|
<a href="/u/{{.CurrentUser.Username}}">
|
||||||
<li><a href="/settings">Settings</a></li>
|
<span class="icon"><i class="fa fa-user"></i></span>
|
||||||
<li><a href="/logout">Log out</a></li>
|
My Profile
|
||||||
<li><a href="/account/delete">Delete account</a></li>
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/photo/u/{{.CurrentUser.Username}}">
|
||||||
|
<span class="icon"><i class="fa fa-image"></i></span>
|
||||||
|
My Photos
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/photo/upload">
|
||||||
|
<span class="icon"><i class="fa fa-upload"></i></span>
|
||||||
|
Upload Photos
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/settings">
|
||||||
|
<span class="icon"><i class="fa fa-edit"></i></span>
|
||||||
|
Edit Profile & Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/logout">
|
||||||
|
<span class="icon"><i class="fa fa-arrow-right-from-bracket"></i></span>
|
||||||
|
Log out
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/account/delete">
|
||||||
|
<span class="icon"><i class="fa fa-trash"></i></span>
|
||||||
|
Delete account
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{{define "title"}}User Settings{{end}}
|
{{define "title"}}{{.User.Username}}{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<section class="hero is-info is-bold">
|
<section class="hero is-info is-bold">
|
||||||
|
@ -7,7 +7,7 @@
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<figure class="profile-photo">
|
<figure class="profile-photo">
|
||||||
{{if .User.ProfilePhoto}}
|
{{if .User.ProfilePhoto.ID}}
|
||||||
<img src="/static/photos/{{.User.ProfilePhoto.CroppedFilename}}">
|
<img src="/static/photos/{{.User.ProfilePhoto.CroppedFilename}}">
|
||||||
{{else}}
|
{{else}}
|
||||||
<img class="is-rounded" src="/static/img/shy.png">
|
<img class="is-rounded" src="/static/img/shy.png">
|
||||||
|
@ -138,6 +138,27 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="block p-4">
|
<div class="block p-4">
|
||||||
|
<div class="tabs is-boxed">
|
||||||
|
<ul>
|
||||||
|
<li class="is-active">
|
||||||
|
<a>
|
||||||
|
<span class="icon is-small">
|
||||||
|
<i class="fa fa-user"></i>
|
||||||
|
</span>
|
||||||
|
<span>Profile</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/photo/u/{{.User.Username}}">
|
||||||
|
<span class="icon is-small">
|
||||||
|
<i class="fa fa-image"></i>
|
||||||
|
</span>
|
||||||
|
<span>Photos</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
|
|
||||||
<div class="column is-two-thirds">
|
<div class="column is-two-thirds">
|
||||||
|
|
|
@ -75,7 +75,7 @@
|
||||||
<a class="navbar-link" href="/me">
|
<a class="navbar-link" href="/me">
|
||||||
<figure class="image is-24x24 mr-2">
|
<figure class="image is-24x24 mr-2">
|
||||||
{{if gt .CurrentUser.ProfilePhoto.ID 0}}
|
{{if gt .CurrentUser.ProfilePhoto.ID 0}}
|
||||||
<img src="/static/photos/{{.User.ProfilePhoto.CroppedFilename}}" class="is-rounded">
|
<img src="{{PhotoURL .CurrentUser.ProfilePhoto.CroppedFilename}}" class="is-rounded">
|
||||||
{{else}}
|
{{else}}
|
||||||
<img src="/static/img/shy.png" class="is-rounded has-background-warning">
|
<img src="/static/img/shy.png" class="is-rounded has-background-warning">
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
48
web/templates/photo/delete.html
Normal file
48
web/templates/photo/delete.html
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
{{define "title"}}Delete Photo{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero is-info is-bold">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">
|
||||||
|
Delete Photo
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="block p-4">
|
||||||
|
<div class="level">
|
||||||
|
<div class="level-item">
|
||||||
|
<div class="card" style="max-width: 512px">
|
||||||
|
<header class="card-header has-background-danger">
|
||||||
|
<p class="card-header-title has-text-light">
|
||||||
|
<span class="icon"><i class="fa fa-trash"></i></span>
|
||||||
|
Delete Photo
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div class="card-content">
|
||||||
|
<form method="POST" action="/photo/delete">
|
||||||
|
{{InputCSRF}}
|
||||||
|
<input type="hidden" name="id" value="{{.Photo.ID}}">
|
||||||
|
<input type="hidden" name="confirm" value="true">
|
||||||
|
|
||||||
|
<div class="image block">
|
||||||
|
<img src="{{PhotoURL .Photo.Filename}}">
|
||||||
|
</div>
|
||||||
|
<div class="block">
|
||||||
|
Are you sure you want to delete this photo?
|
||||||
|
</div>
|
||||||
|
<div class="block has-text-center">
|
||||||
|
<button type="submit" class="button is-danger">Delete Photo</button>
|
||||||
|
<button type="button" class="button" onclick="history.back()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
|
@ -5,7 +5,9 @@
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="title">
|
<h1 class="title">
|
||||||
{{if eq .Intent "profile_pic"}}
|
{{if .EditPhoto}}
|
||||||
|
Edit Photo
|
||||||
|
{{else if eq .Intent "profile_pic"}}
|
||||||
Upload a Profile Picture
|
Upload a Profile Picture
|
||||||
{{else}}
|
{{else}}
|
||||||
Upload a Photo
|
Upload a Photo
|
||||||
|
@ -17,11 +19,18 @@
|
||||||
|
|
||||||
{{ $User := .CurrentUser }}
|
{{ $User := .CurrentUser }}
|
||||||
|
|
||||||
|
{{if .EditPhoto}}
|
||||||
|
<form action="/photo/edit" method="POST">
|
||||||
|
<input type="hidden" name="id" value="{{.EditPhoto.ID}}">
|
||||||
|
{{else}}
|
||||||
<form action="/photo/upload" method="POST" enctype="multipart/form-data">
|
<form action="/photo/upload" method="POST" enctype="multipart/form-data">
|
||||||
|
{{end}}
|
||||||
{{InputCSRF}}
|
{{InputCSRF}}
|
||||||
<input type="hidden" name="intent" value="{{.Intent}}">
|
<input type="hidden" id="intent" name="intent" value="{{.Intent}}">
|
||||||
|
|
||||||
<div class="block p-4">
|
<div class="block p-4">
|
||||||
|
<!-- Upload disclaimers, but not if editing a photo -->
|
||||||
|
{{if not .EditPhoto}}
|
||||||
<div class="content block">
|
<div class="content block">
|
||||||
<p>
|
<p>
|
||||||
You can use this page to upload a new photo to your profile. Please remember
|
You can use this page to upload a new photo to your profile. Please remember
|
||||||
|
@ -46,6 +55,7 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
|
@ -54,10 +64,16 @@
|
||||||
<header class="card-header has-background-link">
|
<header class="card-header has-background-link">
|
||||||
<p class="card-header-title has-text-light">
|
<p class="card-header-title has-text-light">
|
||||||
<i class="fa fa-camera pr-2"></i>
|
<i class="fa fa-camera pr-2"></i>
|
||||||
|
{{if .EditPhoto}}
|
||||||
|
Your Photo
|
||||||
|
{{else}}
|
||||||
Select a Photo
|
Select a Photo
|
||||||
|
{{end}}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Upload field, not when editing -->
|
||||||
|
{{if not .EditPhoto}}
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<p class="block">
|
<p class="block">
|
||||||
Browse or drag a photo onto this page:
|
Browse or drag a photo onto this page:
|
||||||
|
@ -102,15 +118,40 @@
|
||||||
<!-- Container of img tags for the selected photo preview. -->
|
<!-- Container of img tags for the selected photo preview. -->
|
||||||
<div id="previewBox" class="block"></div>
|
<div id="previewBox" class="block"></div>
|
||||||
|
|
||||||
|
{{if .NeedsCrop}}
|
||||||
|
<div class="block has-text-centered">
|
||||||
|
<button type="button" class="button block is-info" onclick="resetCrop()">Reset</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Holder of image crop coordinates in x,y,w,h format. -->
|
||||||
|
<input type="hidden" name="crop" id="cropCoords">
|
||||||
|
</div>
|
||||||
|
{{else}}<!-- when .EditPhoto -->
|
||||||
|
<div class="card-content">
|
||||||
|
<figure id="editphoto-preview" class="image block">
|
||||||
|
<img src="{{PhotoURL .EditPhoto.Filename}}" id="editphoto-img">
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<div class="block has-text-centered" id="editphoto-begin-crop">
|
||||||
|
<button type="button" class="button" onclick="setProfilePhoto()">
|
||||||
|
<span class="icon"><i class="fa fa-crop-simple"></i></span>
|
||||||
|
<span>Set this as my profile photo (crop image)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block has-text-centered" id="editphoto-cropping" style="display: none">
|
||||||
<button type="button" class="button block is-info" onclick="resetCrop()">Reset</button>
|
<button type="button" class="button block is-info" onclick="resetCrop()">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Holder of image crop coordinates in x,y,w,h format. -->
|
<!-- Holder of image crop coordinates in x,y,w,h format. -->
|
||||||
<input type="text" name="crop" id="cropCoords">
|
<input type="hidden" name="crop" id="cropCoords">
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
</div>
|
</div><!-- /card -->
|
||||||
|
</div><!-- /column -->
|
||||||
|
|
||||||
<div class="column">
|
<div class="column">
|
||||||
|
|
||||||
|
@ -129,7 +170,8 @@
|
||||||
<input type="text" class="input"
|
<input type="text" class="input"
|
||||||
name="caption"
|
name="caption"
|
||||||
id="caption"
|
id="caption"
|
||||||
placeholder="Caption">
|
placeholder="Caption"
|
||||||
|
value="{{.EditPhoto.Caption}}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
@ -151,7 +193,8 @@
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="explicit"
|
name="explicit"
|
||||||
value="true">
|
value="true"
|
||||||
|
{{if .EditPhoto.Explicit}}checked{{end}}>
|
||||||
This photo contains explicit content
|
This photo contains explicit content
|
||||||
</label>
|
</label>
|
||||||
<p class="help">
|
<p class="help">
|
||||||
|
@ -170,7 +213,7 @@
|
||||||
<input type="radio"
|
<input type="radio"
|
||||||
name="visibility"
|
name="visibility"
|
||||||
value="public"
|
value="public"
|
||||||
checked>
|
{{if or (not .EditPhoto) (eq .EditPhoto.Visibility "public")}}checked{{end}}>
|
||||||
<strong>Public:</strong> this photo will appear on your profile page
|
<strong>Public:</strong> this photo will appear on your profile page
|
||||||
and can be seen by any logged-in user account. It may also appear
|
and can be seen by any logged-in user account. It may also appear
|
||||||
on the site-wide Photo Gallery if that option is enabled, below.
|
on the site-wide Photo Gallery if that option is enabled, below.
|
||||||
|
@ -180,7 +223,8 @@
|
||||||
<label class="radio">
|
<label class="radio">
|
||||||
<input type="radio"
|
<input type="radio"
|
||||||
name="visibility"
|
name="visibility"
|
||||||
value="friends">
|
value="friends"
|
||||||
|
{{if eq .EditPhoto.Visibility "friends"}}checked{{end}}>
|
||||||
<strong>Friends only:</strong> only users you have added as a friend
|
<strong>Friends only:</strong> only users you have added as a friend
|
||||||
can see this photo on your profile page.
|
can see this photo on your profile page.
|
||||||
</label>
|
</label>
|
||||||
|
@ -189,7 +233,8 @@
|
||||||
<label class="radio">
|
<label class="radio">
|
||||||
<input type="radio"
|
<input type="radio"
|
||||||
name="visibility"
|
name="visibility"
|
||||||
value="private">
|
value="private"
|
||||||
|
{{if eq .EditPhoto.Visibility "private"}}checked{{end}}>
|
||||||
<strong>Private:</strong> this photo is not visible to anybody except
|
<strong>Private:</strong> this photo is not visible to anybody except
|
||||||
for people whom you allow to see your private pictures (not implemented yet!)
|
for people whom you allow to see your private pictures (not implemented yet!)
|
||||||
</label>
|
</label>
|
||||||
|
@ -202,7 +247,8 @@
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="gallery"
|
name="gallery"
|
||||||
value="true"
|
value="true"
|
||||||
checked>
|
checked
|
||||||
|
{{if .EditPhoto.Gallery}}checked{{end}}>
|
||||||
Show this photo in the site-wide Photo Gallery (public photos only)
|
Show this photo in the site-wide Photo Gallery (public photos only)
|
||||||
</label>
|
</label>
|
||||||
<p class="help">
|
<p class="help">
|
||||||
|
@ -213,6 +259,7 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if not .EditPhoto}}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">Confirm Upload</label>
|
<label class="label">Confirm Upload</label>
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
|
@ -236,9 +283,16 @@
|
||||||
<input type="hidden" name="confirm2" value="true">
|
<input type="hidden" name="confirm2" value="true">
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<button type="submit" class="button is-primary">Upload Photo</button>
|
<button type="submit" class="button is-primary">
|
||||||
|
{{if .EditPhoto}}
|
||||||
|
Save Changes
|
||||||
|
{{else}}
|
||||||
|
Upload Photo
|
||||||
|
{{end}}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
@ -256,7 +310,7 @@
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
var croppr = null;
|
var croppr = null;
|
||||||
const usingCroppr = true;
|
const usingCroppr = {{if .NeedsCrop}}true{{else}}false{{end}};
|
||||||
|
|
||||||
function resetCrop() {
|
function resetCrop() {
|
||||||
if (croppr !== null) {
|
if (croppr !== null) {
|
||||||
|
@ -264,6 +318,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{{if not .EditPhoto}}
|
||||||
window.addEventListener("DOMContentLoaded", (event) => {
|
window.addEventListener("DOMContentLoaded", (event) => {
|
||||||
let $file = document.querySelector("#file"),
|
let $file = document.querySelector("#file"),
|
||||||
$fileName = document.querySelector("#fileName"),
|
$fileName = document.querySelector("#fileName"),
|
||||||
|
@ -335,7 +390,51 @@
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
});
|
});
|
||||||
})
|
});
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
// EditPhoto only: a button to crop their photo to set as a profile pic.
|
||||||
|
function setProfilePhoto() {
|
||||||
|
let $begin = document.querySelector("#editphoto-begin-crop"),
|
||||||
|
$cropRow = document.querySelector("#editphoto-cropping"),
|
||||||
|
$preview = document.querySelector("#editphoto-preview")
|
||||||
|
$cropField = document.querySelector("#cropCoords"),
|
||||||
|
$intent = document.querySelector("#intent");
|
||||||
|
img = document.querySelector("#editphoto-img");
|
||||||
|
|
||||||
|
// Toggle the button display, from begin crop to the crop reset button.
|
||||||
|
$begin.style.display = 'none';
|
||||||
|
$cropRow.style.display = 'block';
|
||||||
|
|
||||||
|
// Set intent to profile-pic so when the form posts the crop coords will
|
||||||
|
// create a new profile pic for this user.
|
||||||
|
$intent.value = "profile-pic";
|
||||||
|
|
||||||
|
croppr = new Croppr(img, {
|
||||||
|
aspectRatio: 1,
|
||||||
|
minSize: [ 32, 32, 'px' ],
|
||||||
|
returnMode: 'real',
|
||||||
|
onCropStart: (data) => {
|
||||||
|
// console.log(data);
|
||||||
|
},
|
||||||
|
onCropMove: (data) => {
|
||||||
|
// console.log(data);
|
||||||
|
},
|
||||||
|
onCropEnd: (data) => {
|
||||||
|
// console.log(data);
|
||||||
|
$cropField.value = [
|
||||||
|
data.x, data.y, data.width, data.height
|
||||||
|
].join(",");
|
||||||
|
},
|
||||||
|
onInitialize: (inst) => {
|
||||||
|
// Populate the default crop value into the form field.
|
||||||
|
let data = inst.getValue();
|
||||||
|
$cropField.value = [
|
||||||
|
data.x, data.y, data.width, data.height
|
||||||
|
].join(",");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
240
web/templates/photo/user_photos.html
Normal file
240
web/templates/photo/user_photos.html
Normal file
|
@ -0,0 +1,240 @@
|
||||||
|
{{define "title"}}Photos of {{.User.Username}}{{end}}
|
||||||
|
|
||||||
|
<!-- Reusable card body -->
|
||||||
|
{{define "card-body"}}
|
||||||
|
<div>
|
||||||
|
<small class="has-text-grey">Uploaded {{.CreatedAt.Format "Jan _2 2006 15:04:05"}}</small>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
{{if .Explicit}}
|
||||||
|
<span class="tag is-danger is-light">
|
||||||
|
<span class="icon"><i class="fa fa-fire"></i></span>
|
||||||
|
<span>Explicit</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<span class="tag is-info is-light">
|
||||||
|
<span class="icon"><i class="fa fa-eye"></i></span>
|
||||||
|
<span>
|
||||||
|
{{if eq .Visibility "public"}}Public{{end}}
|
||||||
|
{{if eq .Visibility "private"}}Private{{end}}
|
||||||
|
{{if eq .Visibility "friends"}}Friends{{end}}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{{if .Gallery}}
|
||||||
|
<span class="tag is-success is-light">
|
||||||
|
<span class="icon"><i class="fa fa-image"></i></span>
|
||||||
|
<span>Gallery</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Reusable card footer -->
|
||||||
|
{{define "card-footer"}}
|
||||||
|
<footer class="card-footer">
|
||||||
|
<a class="card-footer-item" href="/photo/edit?id={{.ID}}">
|
||||||
|
<span class="icon"><i class="fa fa-edit"></i></span>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<a class="card-footer-item has-text-danger" href="/photo/delete?id={{.ID}}">
|
||||||
|
<span class="icon"><i class="fa fa-trash"></i></span>
|
||||||
|
Delete
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Reusable pager -->
|
||||||
|
{{define "pager"}}
|
||||||
|
<nav class="pagination" role="navigation" aria-label="pagination">
|
||||||
|
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
|
||||||
|
href="{{.Request.URL.Path}}?view={{.ViewStyle}}&page={{.Pager.Previous}}">Previous</a>
|
||||||
|
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
|
||||||
|
href="{{.Request.URL.Path}}?view={{.ViewStyle}}&page={{.Pager.Next}}">Next page</a>
|
||||||
|
<ul class="pagination-list">
|
||||||
|
{{$Root := .}}
|
||||||
|
{{range .Pager.Iter}}
|
||||||
|
<li>
|
||||||
|
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
|
||||||
|
aria-label="Page {{.Page}}"
|
||||||
|
href="{{$Root.Request.URL.Path}}?view={{$Root.ViewStyle}}&page={{.Page}}">
|
||||||
|
{{.Page}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Main content template -->
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero is-info is-bold">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="level">
|
||||||
|
<div class="level-left">
|
||||||
|
<h1 class="title">
|
||||||
|
Photos of {{.User.Username}}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
{{if .IsOwnPhotos}}
|
||||||
|
<div class="level-right">
|
||||||
|
<div>
|
||||||
|
<a href="/photo/upload" class="button">
|
||||||
|
<span class="icon"><i class="fa fa-upload"></i></span>
|
||||||
|
<span>Upload Photos</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ugly hack.. needed by the card-footers later below. -->
|
||||||
|
{{$Root := .}}
|
||||||
|
|
||||||
|
<div class="block p-4">
|
||||||
|
<div class="tabs is-boxed">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="/u/{{.User.Username}}">
|
||||||
|
<span class="icon is-small">
|
||||||
|
<i class="fa fa-user"></i>
|
||||||
|
</span>
|
||||||
|
<span>Profile</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="is-active">
|
||||||
|
<a>
|
||||||
|
<span class="icon is-small">
|
||||||
|
<i class="fa fa-image"></i>
|
||||||
|
</span>
|
||||||
|
<span>Photos</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo Detail Modal -->
|
||||||
|
<div class="modal" id="detail-modal">
|
||||||
|
<div class="modal-background"></div>
|
||||||
|
<div class="modal-content photo-modal">
|
||||||
|
<div class="image is-fullwidth">
|
||||||
|
<img id="detailImg">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close is-large" aria-label="close"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
<div class="level">
|
||||||
|
<div class="level-left">
|
||||||
|
<div class="level-item">
|
||||||
|
<span>
|
||||||
|
Found <strong>{{.Pager.Total}}</strong> photos (page {{.Pager.Page}} of {{.Pager.Pages}}).
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="level-right">
|
||||||
|
<div class="level-item">
|
||||||
|
<div class="tabs is-toggle is-small">
|
||||||
|
<ul>
|
||||||
|
<li{{if eq .ViewStyle "cards"}} class="is-active"{{end}}>
|
||||||
|
<a href="{{.Request.URL.Path}}?view=cards">Cards</a>
|
||||||
|
</li>
|
||||||
|
<li{{if eq .ViewStyle "full"}} class="is-active"{{end}}>
|
||||||
|
<a href="{{.Request.URL.Path}}?view=full">Full</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "pager" .}}
|
||||||
|
|
||||||
|
<!-- "Full" view style? (blog style) -->
|
||||||
|
{{if eq .ViewStyle "full"}}
|
||||||
|
{{range .Photos}}
|
||||||
|
<div class="card block">
|
||||||
|
<header class="card-header has-background-link">
|
||||||
|
<p class="card-header-title has-text-light">
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fa fa-image"></i>
|
||||||
|
</span>
|
||||||
|
{{or .Caption "Photo"}}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<img src="{{PhotoURL .Filename}}">
|
||||||
|
|
||||||
|
{{template "card-body" .}}
|
||||||
|
</div>
|
||||||
|
{{if or $Root.IsOwnPhotos $Root.CurrentUser.IsAdmin}}
|
||||||
|
{{template "card-footer" .}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- "Cards" style (default) -->
|
||||||
|
{{else}}
|
||||||
|
<div class="columns is-multiline">
|
||||||
|
{{range .Photos}}
|
||||||
|
<div class="column is-one-quarter-desktop is-half-tablet">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-image">
|
||||||
|
<figure class="image">
|
||||||
|
<a href="{{PhotoURL .Filename}}" target="_blank"
|
||||||
|
class="js-modal-trigger" data-target="detail-modal"
|
||||||
|
onclick="setModalImage(this.href)">
|
||||||
|
<img src="{{PhotoURL .Filename}}">
|
||||||
|
</a>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
{{if .Caption}}
|
||||||
|
{{.Caption}}
|
||||||
|
{{else}}<em>No caption</em>{{end}}
|
||||||
|
|
||||||
|
{{template "card-body" .}}
|
||||||
|
</div>
|
||||||
|
{{if or $Root.IsOwnPhotos $Root.CurrentUser.IsAdmin}}
|
||||||
|
{{template "card-footer" .}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}<!-- ViewStyle -->
|
||||||
|
|
||||||
|
{{template "pager" .}}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
// Get our modal to trigger it on click of a detail img.
|
||||||
|
let $modal = document.querySelector("#detail-modal");
|
||||||
|
document.querySelectorAll(".js-modal-trigger").forEach(node => {
|
||||||
|
node.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setModalImage(node.href);
|
||||||
|
$modal.classList.add("is-active");
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
function setModalImage(url) {
|
||||||
|
let $modalImg = document.querySelector("#detailImg");
|
||||||
|
$modalImg.src = url;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
Reference in New Issue
Block a user