Photo Upload & Profile Pictures
Basic photo upload support. Square cropped images still buggy.
This commit is contained in:
parent
c43d052665
commit
b72973e741
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
/gosocial
|
||||
/web/static/photos
|
||||
database.sqlite
|
||||
settings.json
|
||||
settings.json
|
||||
|
|
4
go.mod
4
go.mod
|
@ -18,6 +18,8 @@ require (
|
|||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f // indirect
|
||||
github.com/go-redis/redis v6.15.9+incompatible // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
|
@ -34,6 +36,7 @@ require (
|
|||
github.com/microcosm-cc/bluemonday v1.0.19 // indirect
|
||||
github.com/russross/blackfriday v1.5.2 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||
github.com/sergi/go-diff v1.2.0 // indirect
|
||||
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 // indirect
|
||||
github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480 // indirect
|
||||
|
@ -44,6 +47,7 @@ require (
|
|||
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect
|
||||
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 // indirect
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect
|
||||
|
|
10
go.sum
10
go.sum
|
@ -16,6 +16,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA=
|
||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
|
||||
|
@ -113,6 +117,8 @@ github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNue
|
|||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
|
||||
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
|
@ -172,6 +178,10 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y
|
|||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 h1:/eM0PCrQI2xd471rI+snWuu251/+/jpBpZqir2mPdnU=
|
||||
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
|
|
|
@ -17,6 +17,12 @@ const (
|
|||
TemplatePath = "./web/templates"
|
||||
StaticPath = "./web/static"
|
||||
SettingsPath = "./settings.json"
|
||||
|
||||
// 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/"
|
||||
PhotoDiskPath = "./web/static/photos"
|
||||
)
|
||||
|
||||
// Security
|
||||
|
@ -27,6 +33,7 @@ const (
|
|||
CSRFInputName = "_csrf" // html input name
|
||||
SessionCookieMaxAge = 60 * 60 * 24 * 30
|
||||
SessionRedisKeyFormat = "session/%s"
|
||||
MultipartMaxMemory = 1024 * 1024 * 1024 * 20 // 20 MB
|
||||
)
|
||||
|
||||
// Authentication
|
||||
|
@ -42,6 +49,12 @@ var (
|
|||
UsernameRegexp = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
|
||||
)
|
||||
|
||||
// Photo Galleries
|
||||
const (
|
||||
MaxPhotoWidth = 1280
|
||||
ProfilePhotoWidth = 512
|
||||
)
|
||||
|
||||
// Variables set by main.go to make them readily available.
|
||||
var (
|
||||
RuntimeVersion string
|
||||
|
|
144
pkg/controller/photo/upload.go
Normal file
144
pkg/controller/photo/upload.go
Normal file
|
@ -0,0 +1,144 @@
|
|||
package photo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||
"git.kirsle.net/apps/gosocial/pkg/photo"
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||
)
|
||||
|
||||
// Upload photos controller.
|
||||
func Upload() http.HandlerFunc {
|
||||
tmpl := templates.Must("photo/upload.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var vars = map[string]interface{}{
|
||||
"Intent": r.FormValue("intent"),
|
||||
"NeedsCrop": false,
|
||||
}
|
||||
|
||||
// Query string parameters: what is the intent of this photo upload?
|
||||
// - If profile picture, the user will crop their image before posting it.
|
||||
// - If regular photo, user simply picks a picture and doesn't need to crop it.
|
||||
if vars["Intent"] == "profile_pic" {
|
||||
vars["NeedsCrop"] = true
|
||||
}
|
||||
|
||||
user, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
||||
}
|
||||
|
||||
// Are they POSTing?
|
||||
if r.Method == http.MethodPost {
|
||||
var (
|
||||
caption = r.PostFormValue("caption")
|
||||
isExplicit = r.PostFormValue("explicit") == "true"
|
||||
visibility = r.PostFormValue("visibility")
|
||||
isGallery = r.PostFormValue("gallery") == "true"
|
||||
cropCoords = r.PostFormValue("crop")
|
||||
confirm1 = r.PostFormValue("confirm1") == "true"
|
||||
confirm2 = r.PostFormValue("confirm2") == "true"
|
||||
)
|
||||
|
||||
// They checked both boxes. The browser shouldn't allow them to
|
||||
// post but validate it here anyway...
|
||||
if !confirm1 || !confirm2 {
|
||||
session.FlashError(w, r, "You must agree to the terms to upload this picture.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Error("parsed crop coords: %+v", crop)
|
||||
|
||||
// Get their file upload.
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Error receiving your file: %s", err)
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// Read the file contents.
|
||||
log.Debug("Receiving uploaded file (%d bytes): %s", header.Size, header.Filename)
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, file)
|
||||
|
||||
filename, cropFilename, err := photo.UploadPhoto(photo.UploadConfig{
|
||||
User: user,
|
||||
Extension: filepath.Ext(header.Filename),
|
||||
Data: buf.Bytes(),
|
||||
Crop: crop,
|
||||
})
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Error in UploadPhoto: %s", err)
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// Configuration for the DB entry.
|
||||
ptmpl := models.Photo{
|
||||
UserID: user.ID,
|
||||
Filename: filename,
|
||||
CroppedFilename: cropFilename,
|
||||
Caption: caption,
|
||||
Visibility: models.PhotoVisibility(visibility),
|
||||
Gallery: isGallery,
|
||||
Explicit: isExplicit,
|
||||
}
|
||||
|
||||
// Get the filesize.
|
||||
if stat, err := os.Stat(photo.DiskPath(filename)); err == nil {
|
||||
ptmpl.Filesize = stat.Size()
|
||||
}
|
||||
|
||||
// Create it in DB!
|
||||
p, err := models.CreatePhoto(ptmpl)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't create Photo in DB: %s", err)
|
||||
} else {
|
||||
log.Info("New photo! %+v", p)
|
||||
}
|
||||
|
||||
// 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.Save()
|
||||
}
|
||||
|
||||
session.Flash(w, r, "Your photo has been uploaded successfully.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
|
@ -3,6 +3,7 @@ package middleware
|
|||
import (
|
||||
"net/http"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||
)
|
||||
|
@ -13,6 +14,7 @@ func LoginRequired(handler http.Handler) http.Handler {
|
|||
|
||||
// User must be logged in.
|
||||
if _, err := session.CurrentUser(r); err != nil {
|
||||
log.Error("LoginRequired: %s", err)
|
||||
errhandler := templates.MakeErrorPage("Login Required", "You must be signed in to view this page.", http.StatusForbidden)
|
||||
errhandler.ServeHTTP(w, r)
|
||||
return
|
||||
|
|
|
@ -20,7 +20,7 @@ func CSRF(handler http.Handler) http.Handler {
|
|||
|
||||
// If we are running a POST request, validate the CSRF form value.
|
||||
if r.Method != http.MethodGet {
|
||||
r.ParseForm()
|
||||
r.ParseMultipartForm(config.MultipartMaxMemory)
|
||||
check := r.FormValue(config.CSRFInputName)
|
||||
if check != token {
|
||||
log.Error("CSRF mismatch! %s <> %s", check, token)
|
||||
|
|
|
@ -10,4 +10,5 @@ var DB *gorm.DB
|
|||
func AutoMigrate() {
|
||||
DB.AutoMigrate(&User{})
|
||||
DB.AutoMigrate(&ProfileField{})
|
||||
DB.AutoMigrate(&Photo{})
|
||||
}
|
||||
|
|
51
pkg/models/photo.go
Normal file
51
pkg/models/photo.go
Normal file
|
@ -0,0 +1,51 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Photo table.
|
||||
type Photo struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
UserID uint64 `gorm:"index"`
|
||||
Filename string
|
||||
CroppedFilename string // if cropped, e.g. for profile photo
|
||||
Filesize int64
|
||||
Caption string
|
||||
Flagged bool // photo has been reported by the community
|
||||
Visibility PhotoVisibility
|
||||
Gallery bool // photo appears in the public gallery (if public)
|
||||
Explicit bool // is an explicit photo
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// PhotoVisibility settings.
|
||||
type PhotoVisibility string
|
||||
|
||||
const (
|
||||
PhotoPublic PhotoVisibility = "public" // on profile page and/or public gallery
|
||||
PhotoFriends = "friends" // only friends can see it
|
||||
PhotoPrivate = "private" // private
|
||||
)
|
||||
|
||||
// CreatePhoto with most of the settings you want (not ID or timestamps) in the database.
|
||||
func CreatePhoto(tmpl Photo) (*Photo, error) {
|
||||
if tmpl.UserID == 0 {
|
||||
return nil, errors.New("UserID required")
|
||||
}
|
||||
|
||||
p := &Photo{
|
||||
UserID: tmpl.UserID,
|
||||
Filename: tmpl.Filename,
|
||||
CroppedFilename: tmpl.CroppedFilename,
|
||||
Caption: tmpl.Caption,
|
||||
Visibility: tmpl.Visibility,
|
||||
Gallery: tmpl.Gallery,
|
||||
Explicit: tmpl.Explicit,
|
||||
}
|
||||
|
||||
result := DB.Create(p)
|
||||
return p, result.Error
|
||||
}
|
|
@ -23,12 +23,15 @@ type User struct {
|
|||
Name *string
|
||||
Birthdate time.Time
|
||||
Certified bool
|
||||
Explicit bool // user has opted-in to see explicit content
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
UpdatedAt time.Time `gorm:"index"`
|
||||
LastLoginAt time.Time `gorm:"index"`
|
||||
|
||||
// Relational tables.
|
||||
ProfileField []ProfileField
|
||||
ProfileField []ProfileField
|
||||
ProfilePhotoID uint64
|
||||
ProfilePhoto Photo
|
||||
}
|
||||
|
||||
// UserStatus options.
|
||||
|
|
62
pkg/photo/filenames.go
Normal file
62
pkg/photo/filenames.go
Normal file
|
@ -0,0 +1,62 @@
|
|||
package photo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Functions that deal with giving photos their:
|
||||
// - Filename
|
||||
// - URL prefix (/static/photos or maybe CDN?)
|
||||
|
||||
/*
|
||||
NewFilename generates a Filename with an extension (".jpg").
|
||||
|
||||
The filename is a random UUID string, with a couple of directory
|
||||
paths in front consisting of the first few characters (to keep
|
||||
directory sizes under control over time). Example:
|
||||
|
||||
"91/b9/91b908db-4007-41b2-bbca-71a6526e59aa.jpg"
|
||||
*/
|
||||
func NewFilename(ext string) string {
|
||||
basename := uuid.New().String()
|
||||
first2 := basename[:2]
|
||||
next2 := basename[2:4]
|
||||
log.Debug("photo.NewFilename: UUID %s first2 %d next2 %d", basename, first2, next2)
|
||||
return fmt.Sprintf(
|
||||
"%s/%s/%s%s",
|
||||
first2, next2, basename, ext,
|
||||
)
|
||||
}
|
||||
|
||||
// DiskPath returns the local disk path to a photo Filename.
|
||||
func DiskPath(filename string) string {
|
||||
return config.PhotoDiskPath + "/" + filename
|
||||
}
|
||||
|
||||
/*
|
||||
EnsurePath makes sure the local './web/static/photos/' path is ready
|
||||
to write an image to, taking into account path parameters in the
|
||||
image filename.
|
||||
|
||||
The filename is like from NewFilename(), just the photo Filename portion.
|
||||
It is appended to the PhotoDiskPath.
|
||||
|
||||
Returns the full path ("./web/static/photos/...") ready for the caller
|
||||
to use it for writing.
|
||||
*/
|
||||
func EnsurePath(filename string) (string, error) {
|
||||
fullpath := DiskPath(filename)
|
||||
dir := filepath.Dir(fullpath)
|
||||
log.Debug("photo.EnsurePath: check that %s exists", dir)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fullpath, fmt.Errorf("EnsurePath: %s", err)
|
||||
} else {
|
||||
return fullpath, nil
|
||||
}
|
||||
}
|
173
pkg/photo/upload.go
Normal file
173
pkg/photo/upload.go
Normal file
|
@ -0,0 +1,173 @@
|
|||
package photo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||
"github.com/edwvee/exiffix"
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
type UploadConfig struct {
|
||||
User *models.User
|
||||
Extension string // 'jpg' or 'png' only.
|
||||
Data []byte
|
||||
Crop []int // x, y, w, h
|
||||
}
|
||||
|
||||
// UploadPhoto handles an incoming photo to add to a user's account.
|
||||
//
|
||||
// Returns:
|
||||
// - NewFilename() of the created photo file on disk.
|
||||
// - NewFilename() of the cropped version, or "" if not cropping.
|
||||
// - error on errors
|
||||
func UploadPhoto(cfg UploadConfig) (string, string, error) {
|
||||
// Validate and normalize the extension.
|
||||
var extension = cfg.Extension
|
||||
switch cfg.Extension {
|
||||
case ".jpg":
|
||||
fallthrough
|
||||
case ".jpe":
|
||||
fallthrough
|
||||
case ".jpeg":
|
||||
extension = ".jpg"
|
||||
case ".png":
|
||||
extension = ".png"
|
||||
default:
|
||||
return "", "", errors.New("unsupported image extension, must be jpg or png")
|
||||
}
|
||||
|
||||
// Decide on a filename for this photo.
|
||||
var (
|
||||
filename = NewFilename(extension)
|
||||
cropFilename = NewFilename(extension)
|
||||
)
|
||||
|
||||
// Decode the image using exiffix, which will auto-rotate jpeg images
|
||||
// based on their EXIF tags.
|
||||
reader := bytes.NewReader(cfg.Data)
|
||||
origImage, _, err := exiffix.Decode(reader)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
if width >= height {
|
||||
if width > config.MaxPhotoWidth {
|
||||
newWidth := config.MaxPhotoWidth
|
||||
log.Debug("(%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)
|
||||
}
|
||||
} else {
|
||||
if height > config.MaxPhotoWidth {
|
||||
newHeight := config.MaxPhotoWidth
|
||||
width = int((float64(width) / float64(height)) * float64(newHeight))
|
||||
height = newHeight
|
||||
log.Debug("Its longest is height, scale to %sx%s", width, height)
|
||||
}
|
||||
}
|
||||
|
||||
// Scale the image.
|
||||
scaledImg := Scale(origImage, image.Rect(0, 0, width, height), draw.ApproxBiLinear)
|
||||
|
||||
// Write the image to disk.
|
||||
if err := ToDisk(filename, extension, scaledImg); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Are we producing a cropped image, too?
|
||||
log.Error("Are we to crop? %+v", cfg.Crop)
|
||||
if cfg.Crop != nil && len(cfg.Crop) >= 4 {
|
||||
log.Debug("Also cropping this image to %+v", cfg.Crop)
|
||||
var (
|
||||
x = cfg.Crop[0]
|
||||
y = cfg.Crop[1]
|
||||
w = cfg.Crop[2]
|
||||
h = cfg.Crop[3]
|
||||
)
|
||||
croppedImg := Crop(origImage, image.Rect(
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
))
|
||||
|
||||
// Write that to disk, too.
|
||||
log.Debug("Writing cropped image to disk: %s", cropFilename)
|
||||
if err := ToDisk(cropFilename, extension, croppedImg); err != nil {
|
||||
return filename, "", err
|
||||
}
|
||||
|
||||
// Return both filenames!
|
||||
return filename, cropFilename, nil
|
||||
}
|
||||
|
||||
// Not cropping, return only the first filename.
|
||||
return filename, "", nil
|
||||
}
|
||||
|
||||
// Scale down an image. Example:
|
||||
//
|
||||
// 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)
|
||||
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)
|
||||
return dst
|
||||
}
|
||||
|
||||
// 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/"
|
||||
func ToDisk(filename string, extension string, img image.Image) error {
|
||||
if path, err := EnsurePath(filename); err == nil {
|
||||
fh, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fh.Close()
|
||||
|
||||
switch extension {
|
||||
case ".jpg":
|
||||
jpeg.Encode(fh, img, &jpeg.Options{
|
||||
Quality: config.JpegQuality,
|
||||
})
|
||||
case ".png":
|
||||
png.Encode(fh, img)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("couldn't EnsurePath: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -8,6 +8,7 @@ import (
|
|||
"git.kirsle.net/apps/gosocial/pkg/controller/account"
|
||||
"git.kirsle.net/apps/gosocial/pkg/controller/api"
|
||||
"git.kirsle.net/apps/gosocial/pkg/controller/index"
|
||||
"git.kirsle.net/apps/gosocial/pkg/controller/photo"
|
||||
"git.kirsle.net/apps/gosocial/pkg/middleware"
|
||||
)
|
||||
|
||||
|
@ -24,6 +25,7 @@ func New() http.Handler {
|
|||
mux.Handle("/me", middleware.LoginRequired(account.Dashboard()))
|
||||
mux.Handle("/settings", middleware.LoginRequired(account.Settings()))
|
||||
mux.Handle("/u/", middleware.LoginRequired(account.Profile()))
|
||||
mux.Handle("/photo/upload", middleware.LoginRequired(photo.Upload()))
|
||||
|
||||
// JSON API endpoints.
|
||||
mux.HandleFunc("/v1/version", api.Version())
|
||||
|
|
21
web/static/css/theme.css
Normal file
21
web/static/css/theme.css
Normal file
|
@ -0,0 +1,21 @@
|
|||
/* Custom CSS styles */
|
||||
|
||||
/* Container for large profile pic on user pages */
|
||||
.profile-photo {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
display: block;
|
||||
border: 1px solid #000;
|
||||
background-color: #fff;
|
||||
padding: 4px;
|
||||
position: relative;
|
||||
}
|
||||
.profile-photo img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.profile-photo .corner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
58
web/static/js/croppr/croppr.css
Normal file
58
web/static/js/croppr/croppr.css
Normal file
|
@ -0,0 +1,58 @@
|
|||
.croppr-container * {
|
||||
user-select: none;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
box-sizing: border-box;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
}
|
||||
|
||||
.croppr-container img {
|
||||
vertical-align: middle;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.croppr {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.croppr-overlay {
|
||||
background: rgba(0,0,0,0.5);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.croppr-region {
|
||||
border: 1px dashed rgba(0, 0, 0, 0.5);
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
cursor: move;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.croppr-imageClipped {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.croppr-handle {
|
||||
border: 1px solid black;
|
||||
background-color: white;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0;
|
||||
}
|
1189
web/static/js/croppr/croppr.js
Normal file
1189
web/static/js/croppr/croppr.js
Normal file
File diff suppressed because it is too large
Load Diff
1
web/static/js/croppr/croppr.min.css
vendored
Normal file
1
web/static/js/croppr/croppr.min.css
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
.croppr-container *{user-select:none;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;box-sizing:border-box;-webkit-box-sizing:border-box;-moz-box-sizing:border-box}.croppr-container img{vertical-align:middle;max-width:100%}.croppr{position:relative;display:inline-block}.croppr-handle,.croppr-imageClipped,.croppr-overlay,.croppr-region{position:absolute;top:0}.croppr-overlay{background:rgba(0,0,0,.5);right:0;bottom:0;left:0;z-index:1;cursor:crosshair}.croppr-region{border:1px dashed rgba(0,0,0,.5);z-index:3;cursor:move}.croppr-imageClipped{right:0;bottom:0;left:0;z-index:2;pointer-events:none}.croppr-handle{border:1px solid #000;background-color:#fff;width:10px;height:10px;z-index:4}
|
1
web/static/js/croppr/croppr.min.js
vendored
Normal file
1
web/static/js/croppr/croppr.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -20,6 +20,7 @@
|
|||
<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>
|
||||
|
|
|
@ -6,8 +6,23 @@
|
|||
<div class="container">
|
||||
<div class="columns">
|
||||
<div class="column is-narrow">
|
||||
<figure class="image is-128x128">
|
||||
<figure class="profile-photo">
|
||||
{{if .User.ProfilePhoto}}
|
||||
<img src="/static/photos/{{.User.ProfilePhoto.CroppedFilename}}">
|
||||
{{else}}
|
||||
<img class="is-rounded" src="/static/img/shy.png">
|
||||
{{end}}
|
||||
|
||||
<!-- CurrentUser can upload a new profile pic -->
|
||||
{{if eq .CurrentUser.ID .User.ID}}
|
||||
<span class="corner">
|
||||
<button class="button is-small p-1 is-success">
|
||||
<a href="/photo/upload?intent=profile_pic"
|
||||
class="fa fa-camera has-text-link"
|
||||
title="Upload a new Profile Picture"></a>
|
||||
</button>
|
||||
</span>
|
||||
{{end}}
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="/static/fontawesome-free-6.1.2-web/css/all.css">
|
||||
<link rel="stylesheet" href="/static/css/theme.css">
|
||||
<title>{{template "title" .}} - {{ .Title }}</title>
|
||||
</head>
|
||||
<body>
|
||||
|
@ -73,7 +74,11 @@
|
|||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<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">
|
||||
{{else}}
|
||||
<img src="/static/img/shy.png" class="is-rounded has-background-warning">
|
||||
{{end}}
|
||||
</figure>
|
||||
{{.CurrentUser.Username}}
|
||||
</a>
|
||||
|
|
342
web/templates/photo/upload.html
Normal file
342
web/templates/photo/upload.html
Normal file
|
@ -0,0 +1,342 @@
|
|||
{{define "title"}}Upload a Photo{{end}}
|
||||
{{define "content"}}
|
||||
<div class="container">
|
||||
<section class="hero is-info is-bold">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title">
|
||||
{{if eq .Intent "profile_pic"}}
|
||||
Upload a Profile Picture
|
||||
{{else}}
|
||||
Upload a Photo
|
||||
{{end}}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{ $User := .CurrentUser }}
|
||||
|
||||
<form action="/photo/upload" method="POST" enctype="multipart/form-data">
|
||||
{{InputCSRF}}
|
||||
<input type="hidden" name="intent" value="{{.Intent}}">
|
||||
|
||||
<div class="block p-4">
|
||||
<div class="content block">
|
||||
<p>
|
||||
You can use this page to upload a new photo to your profile. Please remember
|
||||
the rules below:
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
🤳 <strong>Self pictures only:</strong> you may only upload pictures which depict
|
||||
<em>you</em> in them. If the picture also contains other people, be sure you
|
||||
have their consent to post it here!
|
||||
</li>
|
||||
<li>
|
||||
🔞 <strong>Mark whether your picture is explicit:</strong> not all nudists want to
|
||||
see sexual content or close-up shots of genitalia. If your picture is not a
|
||||
"normal nude" please check the Explicit box to help the rest of us out!
|
||||
</li>
|
||||
<li>
|
||||
🧑 <strong>Your main profile picture must show your face:</strong> it doesn't have
|
||||
to be a nude pic but your face needs to be in it. Additional photos uploaded to
|
||||
your page do not need to require your face in them.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
|
||||
<div class="card">
|
||||
<header class="card-header has-background-link">
|
||||
<p class="card-header-title has-text-light">
|
||||
<i class="fa fa-camera pr-2"></i>
|
||||
Select a Photo
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
<p class="block">
|
||||
Browse or drag a photo onto this page:
|
||||
</p>
|
||||
|
||||
<div class="field block">
|
||||
<div class="file has-name is-fullwidth">
|
||||
<label class="file-label">
|
||||
<input class="file-input" type="file"
|
||||
name="file"
|
||||
id="file"
|
||||
accept=".jpg,.jpeg,.jpe,.png"
|
||||
required>
|
||||
<span class="file-cta">
|
||||
<span class="file-icon">
|
||||
<i class="fas fa-upload"></i>
|
||||
</span>
|
||||
<span class="file-label">
|
||||
Choose a file…
|
||||
</span>
|
||||
</span>
|
||||
<span class="file-name" id="fileName">
|
||||
Select a file
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box" id="imagePreview" style="display: none">
|
||||
<h3 class="subtitle">
|
||||
{{if .NeedsCrop}}Crop image:{{else}}Selected image:{{end}}
|
||||
</h3>
|
||||
|
||||
{{if .NeedsCrop}}
|
||||
<p class="block">
|
||||
Select a square crop of this image for your profile picture. The full
|
||||
image will go among the rest of your photos, and the square version
|
||||
will be used as your profile pic and avatar.
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
<!-- Container of img tags for the selected photo preview. -->
|
||||
<div id="previewBox" class="block"></div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
|
||||
<div class="card">
|
||||
<header class="card-header has-background-link">
|
||||
<p class="card-header-title has-text-light">
|
||||
<i class="fa fa-pencil pr-2"></i>
|
||||
Photo Settings
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="caption">Caption</label>
|
||||
<input type="text" class="input"
|
||||
name="caption"
|
||||
id="caption"
|
||||
placeholder="Caption">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Explicit Content</label>
|
||||
{{if eq .Intent "profile_pic"}}
|
||||
<span class="has-text-danger">
|
||||
Your default profile picture should
|
||||
<strong class="has-text-danger">NOT</strong>
|
||||
contain explicit content.
|
||||
</span>
|
||||
|
||||
<p class="help">
|
||||
Your default profile picture is about your face. You can have nudity
|
||||
in it, too, but not a close-up shot of your genitals or sporting an
|
||||
erection or engaging in sexual conduct. You can upload pictures like
|
||||
that to your page, just not as your default profile picture!
|
||||
</p>
|
||||
{{else}}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox"
|
||||
name="explicit"
|
||||
value="true">
|
||||
This photo contains explicit content
|
||||
</label>
|
||||
<p class="help">
|
||||
Mark this box if this photo contains any explicit content, including an
|
||||
erect penis, close-up of genitalia, or any depiction of sexual activity.
|
||||
Use your best judgment. "Normal nudes" such as full body nudes in a
|
||||
non-sexual context do not need to check this box.
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Photo Visibility</label>
|
||||
<div>
|
||||
<label class="radio">
|
||||
<input type="radio"
|
||||
name="visibility"
|
||||
value="public"
|
||||
checked>
|
||||
<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.
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="radio">
|
||||
<input type="radio"
|
||||
name="visibility"
|
||||
value="friends">
|
||||
<strong>Friends only:</strong> only users you have added as a friend
|
||||
can see this photo on your profile page.
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="radio">
|
||||
<input type="radio"
|
||||
name="visibility"
|
||||
value="private">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Site Photo Gallery</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox"
|
||||
name="gallery"
|
||||
value="true"
|
||||
checked>
|
||||
Show this photo in the site-wide Photo Gallery (public photos only)
|
||||
</label>
|
||||
<p class="help">
|
||||
Leave this box checked and your (public only) photo can appear in the site's
|
||||
Photo Gallery page. If you uncheck this box, your (public) photo will still
|
||||
appear on your profile page but not on the site photo gallery. Friends-only
|
||||
or private photos never appear in the gallery even if this box is checked.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Confirm Upload</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox"
|
||||
name="confirm1"
|
||||
value="true"
|
||||
required>
|
||||
I assert that this is a photo <strong>of myself</strong> and that I have
|
||||
permission to upload this picture.
|
||||
</label>
|
||||
|
||||
{{if eq .Intent "profile_pic"}}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox"
|
||||
name="confirm2"
|
||||
value="true"
|
||||
required>
|
||||
I assert that this picture shows my face and is not explicit
|
||||
</label>
|
||||
{{else}}
|
||||
<input type="hidden" name="confirm2" value="true">
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button type="submit" class="button is-primary">Upload Photo</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- image cropper -->
|
||||
<!-- <script src="/static/js/jquery-3.6.0.min.js"></script> -->
|
||||
<link rel="stylesheet" href="/static/js/croppr/croppr.min.css">
|
||||
<script src="/static/js/croppr/croppr.js"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
var croppr = null;
|
||||
const usingCroppr = true;
|
||||
|
||||
function resetCrop() {
|
||||
if (croppr !== null) {
|
||||
croppr.reset();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", (event) => {
|
||||
let $file = document.querySelector("#file"),
|
||||
$fileName = document.querySelector("#fileName"),
|
||||
$hiddenPreview = document.querySelector("#imagePreview"),
|
||||
$previewBox = document.querySelector("#previewBox"),
|
||||
$cropField = document.querySelector("#cropCoords");
|
||||
|
||||
// Clear the answer in case of page reload.
|
||||
$cropField.value = "";
|
||||
|
||||
$file.addEventListener("change", function() {
|
||||
let file = this.files[0];
|
||||
$fileName.innerHTML = file.name;
|
||||
|
||||
// Read the image to show the preview on-page.
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => {
|
||||
const uploadedImg = reader.result;
|
||||
$hiddenPreview.style.display = "block";
|
||||
|
||||
// Create a new <img> tag the first time.
|
||||
if (croppr !== null) {
|
||||
croppr.setImage(uploadedImg);
|
||||
croppr.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// If not using croppr, flush the old img preview out.
|
||||
if (!usingCroppr) {
|
||||
$previewBox.innerHTML = "";
|
||||
}
|
||||
|
||||
let img = document.createElement("img");
|
||||
img.src = uploadedImg;
|
||||
img.style.display = "block";
|
||||
img.style.maxWidth = "100%";
|
||||
img.style.height = "auto";
|
||||
|
||||
// Add it to the wrapper div.
|
||||
$previewBox.appendChild(img);
|
||||
|
||||
if (usingCroppr) {
|
||||
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(",");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
</div>
|
||||
{{end}}
|
Reference in New Issue
Block a user