From cd1b349fcccfd2b7567422e74ceaf15953ad87a1 Mon Sep 17 00:00:00 2001 From: Noah Date: Fri, 12 Aug 2022 23:11:36 -0700 Subject: [PATCH] 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. --- pkg/config/config.go | 4 +- pkg/controller/photo/edit_delete.go | 209 +++++++++++++++++++++++ pkg/controller/photo/upload.go | 20 +-- pkg/controller/photo/user_gallery.go | 77 +++++++++ pkg/models/pagination.go | 76 +++++++++ pkg/models/photo.go | 45 +++++ pkg/models/profile.go | 1 - pkg/models/user.go | 28 +++- pkg/photo/filenames.go | 5 + pkg/photo/upload.go | 102 +++++++++--- pkg/router/router.go | 3 + pkg/templates/error_pages.go | 5 + pkg/templates/template_funcs.go | 2 + pkg/templates/template_vars.go | 2 + web/static/css/theme.css | 7 + web/static/js/bulma.js | 43 +++-- web/templates/account/dashboard.html | 41 ++++- web/templates/account/profile.html | 25 ++- web/templates/base.html | 2 +- web/templates/photo/delete.html | 48 ++++++ web/templates/photo/upload.html | 127 ++++++++++++-- web/templates/photo/user_photos.html | 240 +++++++++++++++++++++++++++ 22 files changed, 1029 insertions(+), 83 deletions(-) create mode 100644 pkg/controller/photo/edit_delete.go create mode 100644 pkg/controller/photo/user_gallery.go create mode 100644 pkg/models/pagination.go create mode 100644 web/templates/photo/delete.html create mode 100644 web/templates/photo/user_photos.html diff --git a/pkg/config/config.go b/pkg/config/config.go index ba48f75..89c0ab5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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" ) diff --git a/pkg/controller/photo/edit_delete.go b/pkg/controller/photo/edit_delete.go new file mode 100644 index 0000000..885d86d --- /dev/null +++ b/pkg/controller/photo/edit_delete.go @@ -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 + } + }) +} diff --git a/pkg/controller/photo/upload.go b/pkg/controller/photo/upload.go index d6b0c04..9e98164 100644 --- a/pkg/controller/photo/upload.go +++ b/pkg/controller/photo/upload.go @@ -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 } diff --git a/pkg/controller/photo/user_gallery.go b/pkg/controller/photo/user_gallery.go new file mode 100644 index 0000000..fb2d5b5 --- /dev/null +++ b/pkg/controller/photo/user_gallery.go @@ -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 + } + }) +} diff --git a/pkg/models/pagination.go b/pkg/models/pagination.go new file mode 100644 index 0000000..c8b77b4 --- /dev/null +++ b/pkg/models/pagination.go @@ -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 +} diff --git a/pkg/models/photo.go b/pkg/models/photo.go index 4dbe96a..d08bbc3 100644 --- a/pkg/models/photo.go +++ b/pkg/models/photo.go @@ -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 +} diff --git a/pkg/models/profile.go b/pkg/models/profile.go index 023f36f..d1c99e9 100644 --- a/pkg/models/profile.go +++ b/pkg/models/profile.go @@ -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 diff --git a/pkg/models/user.go b/pkg/models/user.go index 4c9fe8c..182e912 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -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() +} diff --git a/pkg/photo/filenames.go b/pkg/photo/filenames.go index 9faff3b..5f40bcd 100644 --- a/pkg/photo/filenames.go +++ b/pkg/photo/filenames.go @@ -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 diff --git a/pkg/photo/upload.go b/pkg/photo/upload.go index f3fa198..d92bb23 100644 --- a/pkg/photo/upload.go +++ b/pkg/photo/upload.go @@ -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)) +} diff --git a/pkg/router/router.go b/pkg/router/router.go index 46dbd41..6fd0f98 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -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()) diff --git a/pkg/templates/error_pages.go b/pkg/templates/error_pages.go index f4c52b5..a42a8b5 100644 --- a/pkg/templates/error_pages.go +++ b/pkg/templates/error_pages.go @@ -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) { diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go index 65df57b..a823e27 100644 --- a/pkg/templates/template_funcs.go +++ b/pkg/templates/template_funcs.go @@ -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, } } diff --git a/pkg/templates/template_vars.go b/pkg/templates/template_vars.go index c779b3e..75bbec8 100644 --- a/pkg/templates/template_vars.go +++ b/pkg/templates/template_vars.go @@ -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. diff --git a/web/static/css/theme.css b/web/static/css/theme.css index 3cdd903..cf56a37 100644 --- a/web/static/css/theme.css +++ b/web/static/css/theme.css @@ -18,4 +18,11 @@ position: absolute; top: 0; right: 0; +} + +/* Photo modals in addition to Bulma .modal */ +.photo-modal { + width: auto !important; + max-width: fit-content; + max-height: fit-content; } \ No newline at end of file diff --git a/web/static/js/bulma.js b/web/static/js/bulma.js index dae2b12..3b2c15a 100644 --- a/web/static/js/bulma.js +++ b/web/static/js/bulma.js @@ -1,22 +1,29 @@ // Hamburger menu script for mobile. document.addEventListener('DOMContentLoaded', () => { - // Get all "navbar-burger" elements - const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0); - - // Add a click event on each of them - $navbarBurgers.forEach( el => { - el.addEventListener('click', () => { - - // Get the target from the "data-target" attribute - const target = el.dataset.target; - const $target = document.getElementById(target); - - // Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu" - el.classList.toggle('is-active'); - $target.classList.toggle('is-active'); - - }); + // Get all "navbar-burger" elements + const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0); + + // Add a click event on each of them + $navbarBurgers.forEach( el => { + el.addEventListener('click', () => { + + // Get the target from the "data-target" attribute + const target = el.dataset.target; + const $target = document.getElementById(target); + + // Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu" + el.classList.toggle('is-active'); + $target.classList.toggle('is-active'); + }); - - }); \ No newline at end of file + }); + + // 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"); + }) + }) +}); \ No newline at end of file diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index 387c78c..cddbe48 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -19,11 +19,42 @@
diff --git a/web/templates/account/profile.html b/web/templates/account/profile.html index f3f084a..3ff00c2 100644 --- a/web/templates/account/profile.html +++ b/web/templates/account/profile.html @@ -1,4 +1,4 @@ -{{define "title"}}User Settings{{end}} +{{define "title"}}{{.User.Username}}{{end}} {{define "content"}}
@@ -7,7 +7,7 @@
- {{if .User.ProfilePhoto}} + {{if .User.ProfilePhoto.ID}} {{else}} @@ -138,6 +138,27 @@
+ +
diff --git a/web/templates/base.html b/web/templates/base.html index 941d33b..c999c90 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -75,7 +75,7 @@
{{if gt .CurrentUser.ProfilePhoto.ID 0}} - + {{else}} {{end}} diff --git a/web/templates/photo/delete.html b/web/templates/photo/delete.html new file mode 100644 index 0000000..3debb25 --- /dev/null +++ b/web/templates/photo/delete.html @@ -0,0 +1,48 @@ +{{define "title"}}Delete Photo{{end}} +{{define "content"}} +
+
+
+
+

+ Delete Photo +

+
+
+
+ +
+
+
+
+
+

+ + Delete Photo +

+
+
+
+ {{InputCSRF}} + + + +
+ +
+
+ Are you sure you want to delete this photo? +
+
+ + +
+
+
+
+
+
+
+ +
+{{end}} \ No newline at end of file diff --git a/web/templates/photo/upload.html b/web/templates/photo/upload.html index afe6d10..1e923bf 100644 --- a/web/templates/photo/upload.html +++ b/web/templates/photo/upload.html @@ -5,7 +5,9 @@

- {{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}} +
+ + {{else}} + {{end}} {{InputCSRF}} - +
+ + {{if not .EditPhoto}}

You can use this page to upload a new photo to your profile. Please remember @@ -46,6 +55,7 @@

+ {{end}}
@@ -54,10 +64,16 @@ + + {{if not .EditPhoto}}

Browse or drag a photo onto this page: @@ -102,15 +118,40 @@

+ {{if .NeedsCrop}} +
+ +
+ {{end}} +
+ + + +
+ {{else}} +
+
+ +
+ +
+ +
+ + - +
-
+ {{end}} -
+

+
@@ -129,7 +170,8 @@ + placeholder="Caption" + value="{{.EditPhoto.Caption}}">
@@ -151,7 +193,8 @@

@@ -170,7 +213,7 @@ + {{if or (not .EditPhoto) (eq .EditPhoto.Visibility "public")}}checked{{end}}> Public: 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 @@ @@ -189,7 +233,8 @@ @@ -202,7 +247,8 @@ + checked + {{if .EditPhoto.Gallery}}checked{{end}}> Show this photo in the site-wide Photo Gallery (public photos only)

@@ -213,6 +259,7 @@

+ {{if not .EditPhoto}}
+ {{end}}
- +
@@ -256,7 +310,7 @@
diff --git a/web/templates/photo/user_photos.html b/web/templates/photo/user_photos.html new file mode 100644 index 0000000..b61bd2a --- /dev/null +++ b/web/templates/photo/user_photos.html @@ -0,0 +1,240 @@ +{{define "title"}}Photos of {{.User.Username}}{{end}} + + +{{define "card-body"}} +
+ Uploaded {{.CreatedAt.Format "Jan _2 2006 15:04:05"}} +
+
+ {{if .Explicit}} + + + Explicit + + {{end}} + + + + + {{if eq .Visibility "public"}}Public{{end}} + {{if eq .Visibility "private"}}Private{{end}} + {{if eq .Visibility "friends"}}Friends{{end}} + + + + {{if .Gallery}} + + + Gallery + + {{end}} +
+{{end}} + + +{{define "card-footer"}} +
+{{end}} + + +{{define "pager"}} + +{{end}} + + +{{define "content"}} +
+
+
+
+
+

+ Photos of {{.User.Username}} +

+
+ {{if .IsOwnPhotos}} + + {{end}} +
+
+
+ + + {{$Root := .}} + +
+ + + + + +
+
+
+
+ + Found {{.Pager.Total}} photos (page {{.Pager.Page}} of {{.Pager.Pages}}). + + +
+
+ +
+
+
+ +
+
+
+
+ + {{template "pager" .}} + + + {{if eq .ViewStyle "full"}} + {{range .Photos}} +
+ + +
+ + + {{template "card-body" .}} +
+ {{if or $Root.IsOwnPhotos $Root.CurrentUser.IsAdmin}} + {{template "card-footer" .}} + {{end}} +
+ {{end}} + + + {{else}} +
+ {{range .Photos}} +
+
+
+
+ + + +
+
+
+ {{if .Caption}} + {{.Caption}} + {{else}}No caption{{end}} + + {{template "card-body" .}} +
+ {{if or $Root.IsOwnPhotos $Root.CurrentUser.IsAdmin}} + {{template "card-footer" .}} + {{end}} +
+
+ {{end}} +
+ {{end}} + + {{template "pager" .}} + +
+
+ +
+ + +{{end}} \ No newline at end of file