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:
Noah 2022-08-12 23:11:36 -07:00
parent 60dd396b30
commit cd1b349fcc
22 changed files with 1029 additions and 83 deletions

View File

@ -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"
)

View 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
}
})
}

View File

@ -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
}

View 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
View 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
}

View File

@ -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
}

View File

@ -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

View File

@ -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()
}

View File

@ -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

View File

@ -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))
}

View File

@ -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())

View File

@ -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) {

View File

@ -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,
}
}

View File

@ -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.

View File

@ -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;
}

View File

@ -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');
});
});
});
// 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");
})
})
});

View File

@ -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 &amp; 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>

View File

@ -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">

View File

@ -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}}

View 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}}

View File

@ -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">
<input type="hidden" name="crop" id="cropCoords">
</div>
</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>

View 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}}