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
|
||||
const (
|
||||
Title = "gosocial"
|
||||
Title = "nonshy"
|
||||
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
|
||||
// is the base URL that goes in front. TODO: support setting a CDN URL prefix.
|
||||
JpegQuality = 90
|
||||
PhotoWebPath = "/static/photos/"
|
||||
PhotoWebPath = "/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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||
|
@ -59,18 +57,8 @@ func Upload() http.HandlerFunc {
|
|||
|
||||
// Parse and validate crop coordinates.
|
||||
var crop []int
|
||||
if len(cropCoords) > 0 {
|
||||
aints := strings.Split(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
if vars["NeedsCrop"] == true {
|
||||
crop = photo.ParseCropCoords(cropCoords)
|
||||
}
|
||||
|
||||
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.
|
||||
if vars["Intent"] == "profile_pic" {
|
||||
log.Info("User %s is setting their profile picture", user.Username)
|
||||
user.ProfilePhotoID = p.ID
|
||||
user.ProfilePhoto = *p
|
||||
user.Save()
|
||||
}
|
||||
|
||||
session.Flash(w, r, "Your photo has been uploaded successfully.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
templates.Redirect(w, "/photo/u/"+user.Username)
|
||||
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 (
|
||||
"errors"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
@ -49,3 +50,47 @@ func CreatePhoto(tmpl Photo) (*Photo, error) {
|
|||
result := DB.Create(p)
|
||||
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 {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
UserID uint64 `gorm:"index"`
|
||||
User User
|
||||
Name string `gorm:"index"`
|
||||
Value string `gorm:"index"`
|
||||
CreatedAt time.Time
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -8,7 +10,7 @@ import (
|
|||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User account table.
|
||||
|
@ -31,7 +33,12 @@ type User struct {
|
|||
// Relational tables.
|
||||
ProfileField []ProfileField
|
||||
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.
|
||||
|
@ -68,7 +75,7 @@ func CreateUser(username, email, password string) (*User, error) {
|
|||
// GetUser by ID.
|
||||
func GetUser(userId uint64) (*User, error) {
|
||||
user := &User{}
|
||||
result := DB.Preload(clause.Associations).First(&user, userId)
|
||||
result := user.Preload().First(&user, userId)
|
||||
return user, result.Error
|
||||
}
|
||||
|
||||
|
@ -80,10 +87,10 @@ func FindUser(username string) (*User, error) {
|
|||
|
||||
u := &User{}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -157,3 +164,14 @@ func (u *User) Save() error {
|
|||
result := DB.Save(u)
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
to write an image to, taking into account path parameters in the
|
||||
|
|
|
@ -9,6 +9,9 @@ import (
|
|||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"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.
|
||||
reader.Seek(0, io.SeekStart)
|
||||
var width, height int
|
||||
if decoded, _, err := image.DecodeConfig(reader); err == nil {
|
||||
width, height = decoded.Width, decoded.Height
|
||||
} else {
|
||||
return "", "", err
|
||||
}
|
||||
var width, height = origImage.Bounds().Max.X, origImage.Bounds().Max.Y
|
||||
|
||||
// Find the longest edge, if it's too large (over 1280px)
|
||||
// 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 {
|
||||
log.Debug("Its width(%d) is >= its height (%d)", width, height)
|
||||
if width > 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))
|
||||
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 {
|
||||
if height > config.MaxPhotoWidth {
|
||||
|
@ -107,12 +107,7 @@ func UploadPhoto(cfg UploadConfig) (string, string, error) {
|
|||
w = cfg.Crop[2]
|
||||
h = cfg.Crop[3]
|
||||
)
|
||||
croppedImg := Crop(origImage, image.Rect(
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
))
|
||||
croppedImg := Crop(origImage, x, y, w, h)
|
||||
|
||||
// Write that to disk, too.
|
||||
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)
|
||||
func Scale(src image.Image, rect image.Rectangle, scale draw.Scaler) image.Image {
|
||||
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
|
||||
}
|
||||
|
||||
// Crop an image, returning the new image. Example:
|
||||
//
|
||||
// cropped := Crop()
|
||||
func Crop(src image.Image, rect image.Rectangle) image.Image {
|
||||
dst := image.NewRGBA(rect)
|
||||
draw.Copy(dst, image.Point{}, src, rect, draw.Over, nil)
|
||||
func Crop(src image.Image, x, y, w, h int) image.Image {
|
||||
dst := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
srcrect := image.Rect(x, y, x+w, y+h)
|
||||
draw.Copy(dst, image.ZP, src, srcrect, draw.Over, nil)
|
||||
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.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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("/u/", middleware.LoginRequired(account.Profile()))
|
||||
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.
|
||||
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)
|
||||
}()
|
||||
|
||||
// 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 {
|
||||
tmpl := Must("errors/error.html")
|
||||
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/markdown"
|
||||
"git.kirsle.net/apps/gosocial/pkg/photo"
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
"git.kirsle.net/apps/gosocial/pkg/utility"
|
||||
)
|
||||
|
@ -21,6 +22,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
|||
"ComputeAge": utility.Age,
|
||||
"Split": strings.Split,
|
||||
"ToMarkdown": ToMarkdown,
|
||||
"PhotoURL": photo.URLPath,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,8 @@ func MergeVars(r *http.Request, m map[string]interface{}) {
|
|||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
m["Request"] = r
|
||||
}
|
||||
|
||||
// MergeUserVars mixes in global template variables: LoggedIn and CurrentUser. The http.Request is optional.
|
||||
|
|
|
@ -19,3 +19,10 @@
|
|||
top: 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">
|
||||
<ul class="menu-list">
|
||||
<li><a href="/u/{{.CurrentUser.Username}}">My Profile</a></li>
|
||||
<li><a href="/photo/upload">Upload Photos</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
<li><a href="/logout">Log out</a></li>
|
||||
<li><a href="/account/delete">Delete account</a></li>
|
||||
<li>
|
||||
<a href="/u/{{.CurrentUser.Username}}">
|
||||
<span class="icon"><i class="fa fa-user"></i></span>
|
||||
My Profile
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{{define "title"}}User Settings{{end}}
|
||||
{{define "title"}}{{.User.Username}}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="container">
|
||||
<section class="hero is-info is-bold">
|
||||
|
@ -7,7 +7,7 @@
|
|||
<div class="columns">
|
||||
<div class="column is-narrow">
|
||||
<figure class="profile-photo">
|
||||
{{if .User.ProfilePhoto}}
|
||||
{{if .User.ProfilePhoto.ID}}
|
||||
<img src="/static/photos/{{.User.ProfilePhoto.CroppedFilename}}">
|
||||
{{else}}
|
||||
<img class="is-rounded" src="/static/img/shy.png">
|
||||
|
@ -138,6 +138,27 @@
|
|||
</section>
|
||||
|
||||
<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="column is-two-thirds">
|
||||
|
|
|
@ -75,7 +75,7 @@
|
|||
<a class="navbar-link" href="/me">
|
||||
<figure class="image is-24x24 mr-2">
|
||||
{{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}}
|
||||
<img src="/static/img/shy.png" class="is-rounded has-background-warning">
|
||||
{{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="container">
|
||||
<h1 class="title">
|
||||
{{if eq .Intent "profile_pic"}}
|
||||
{{if .EditPhoto}}
|
||||
Edit Photo
|
||||
{{else if eq .Intent "profile_pic"}}
|
||||
Upload a Profile Picture
|
||||
{{else}}
|
||||
Upload a Photo
|
||||
|
@ -17,11 +19,18 @@
|
|||
|
||||
{{ $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">
|
||||
{{end}}
|
||||
{{InputCSRF}}
|
||||
<input type="hidden" name="intent" value="{{.Intent}}">
|
||||
<input type="hidden" id="intent" name="intent" value="{{.Intent}}">
|
||||
|
||||
<div class="block p-4">
|
||||
<!-- Upload disclaimers, but not if editing a photo -->
|
||||
{{if not .EditPhoto}}
|
||||
<div class="content block">
|
||||
<p>
|
||||
You can use this page to upload a new photo to your profile. Please remember
|
||||
|
@ -46,6 +55,7 @@
|
|||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
|
@ -54,10 +64,16 @@
|
|||
<header class="card-header has-background-link">
|
||||
<p class="card-header-title has-text-light">
|
||||
<i class="fa fa-camera pr-2"></i>
|
||||
{{if .EditPhoto}}
|
||||
Your Photo
|
||||
{{else}}
|
||||
Select a Photo
|
||||
{{end}}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Upload field, not when editing -->
|
||||
{{if not .EditPhoto}}
|
||||
<div class="card-content">
|
||||
<p class="block">
|
||||
Browse or drag a photo onto this page:
|
||||
|
@ -102,15 +118,40 @@
|
|||
<!-- Container of img tags for the selected photo preview. -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Holder of image crop coordinates in x,y,w,h format. -->
|
||||
<input type="text" name="crop" id="cropCoords">
|
||||
</div>
|
||||
<input type="hidden" name="crop" id="cropCoords">
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
</div><!-- /card -->
|
||||
</div><!-- /column -->
|
||||
|
||||
<div class="column">
|
||||
|
||||
|
@ -129,7 +170,8 @@
|
|||
<input type="text" class="input"
|
||||
name="caption"
|
||||
id="caption"
|
||||
placeholder="Caption">
|
||||
placeholder="Caption"
|
||||
value="{{.EditPhoto.Caption}}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
@ -151,7 +193,8 @@
|
|||
<label class="checkbox">
|
||||
<input type="checkbox"
|
||||
name="explicit"
|
||||
value="true">
|
||||
value="true"
|
||||
{{if .EditPhoto.Explicit}}checked{{end}}>
|
||||
This photo contains explicit content
|
||||
</label>
|
||||
<p class="help">
|
||||
|
@ -170,7 +213,7 @@
|
|||
<input type="radio"
|
||||
name="visibility"
|
||||
value="public"
|
||||
checked>
|
||||
{{if or (not .EditPhoto) (eq .EditPhoto.Visibility "public")}}checked{{end}}>
|
||||
<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
|
||||
on the site-wide Photo Gallery if that option is enabled, below.
|
||||
|
@ -180,7 +223,8 @@
|
|||
<label class="radio">
|
||||
<input type="radio"
|
||||
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
|
||||
can see this photo on your profile page.
|
||||
</label>
|
||||
|
@ -189,7 +233,8 @@
|
|||
<label class="radio">
|
||||
<input type="radio"
|
||||
name="visibility"
|
||||
value="private">
|
||||
value="private"
|
||||
{{if eq .EditPhoto.Visibility "private"}}checked{{end}}>
|
||||
<strong>Private:</strong> this photo is not visible to anybody except
|
||||
for people whom you allow to see your private pictures (not implemented yet!)
|
||||
</label>
|
||||
|
@ -202,7 +247,8 @@
|
|||
<input type="checkbox"
|
||||
name="gallery"
|
||||
value="true"
|
||||
checked>
|
||||
checked
|
||||
{{if .EditPhoto.Gallery}}checked{{end}}>
|
||||
Show this photo in the site-wide Photo Gallery (public photos only)
|
||||
</label>
|
||||
<p class="help">
|
||||
|
@ -213,6 +259,7 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{{if not .EditPhoto}}
|
||||
<div class="field">
|
||||
<label class="label">Confirm Upload</label>
|
||||
<label class="checkbox">
|
||||
|
@ -236,9 +283,16 @@
|
|||
<input type="hidden" name="confirm2" value="true">
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<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>
|
||||
|
@ -256,7 +310,7 @@
|
|||
|
||||
<script type="text/javascript">
|
||||
var croppr = null;
|
||||
const usingCroppr = true;
|
||||
const usingCroppr = {{if .NeedsCrop}}true{{else}}false{{end}};
|
||||
|
||||
function resetCrop() {
|
||||
if (croppr !== null) {
|
||||
|
@ -264,6 +318,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
{{if not .EditPhoto}}
|
||||
window.addEventListener("DOMContentLoaded", (event) => {
|
||||
let $file = document.querySelector("#file"),
|
||||
$fileName = document.querySelector("#fileName"),
|
||||
|
@ -335,7 +390,51 @@
|
|||
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>
|
||||
|
||||
</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