Private Photo Sharing

* Add ability to unlock your private photos for others.
    * Link to unlock is on another user's Photos page.
    * You can also access your "Manage Private Photos" page in the User Menu
      or Dashboard and add a user by username.
    * See who you have granted access, and see users who have granted you
      access to their private photos.
* Private photos of users who unlocked them for you may appear on the Site
  Gallery if the photo allows.
* Add new filters to the Site Gallery: you can choose to filter by Explicit,
  limit to a specific Visibility, and order by Newest or Oldest. Non-explicit
  users do not get a filter to see explicit content - they must opt-in in
  their settings.
* Bugfix: Site Gallery was not correctly filtering Explicit photos away from
  users who did not opt-in for explicit!
pull/12/head
Noah 2022-09-07 21:18:54 -07:00
parent 31712eba4a
commit de9ba94dd9
19 changed files with 876 additions and 31 deletions

View File

@ -12,6 +12,7 @@ var (
PageSizeMemberSearch = 60
PageSizeFriends = 12
PageSizeBlockList = 12
PageSizePrivatePhotoGrantees = 12
PageSizeAdminCertification = 20
PageSizeAdminFeedback = 20
PageSizeSiteGallery = 16

View File

@ -0,0 +1,157 @@
package photo
import (
"fmt"
"net/http"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// Private controller (/photo/private) to see and modify your Private Photo grants.
func Private() http.HandlerFunc {
// Reuse the upload page but with an EditPhoto variable.
tmpl := templates.Must("photo/private.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
view = r.FormValue("view")
isGrantee = view == "grantee"
)
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: could not get currentUser.")
templates.Redirect(w, "/")
return
}
// Get the users.
pager := &models.Pagination{
PerPage: config.PageSizePrivatePhotoGrantees,
Sort: "updated_at desc",
}
pager.ParsePage(r)
users, err := models.PaginatePrivatePhotoList(currentUser.ID, isGrantee, pager)
if err != nil {
session.FlashError(w, r, "Couldn't paginate users: %s", err)
templates.Redirect(w, "/")
return
}
var vars = map[string]interface{}{
"IsGrantee": isGrantee,
"CountGrantee": models.CountPrivateGrantee(currentUser.ID),
"Users": users,
"Pager": pager,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
// Share your private photos with a new user.
func Share() http.HandlerFunc {
tmpl := templates.Must("photo/share.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// To whom?
var (
user *models.User
username = r.FormValue("to")
isRevokeAll = r.FormValue("intent") == "revoke-all"
)
if username != "" {
if u, err := models.FindUser(username); err != nil {
session.FlashError(w, r, "That username was not found, please try again.")
templates.Redirect(w, r.URL.Path)
return
} else {
user = u
}
}
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: could not get currentUser.")
templates.Redirect(w, "/")
return
}
// Are we revoking our privates from ALL USERS?
if isRevokeAll {
models.RevokePrivatePhotosAll(currentUser.ID)
session.Flash(w, r, "Your private photos have been locked from ALL users.")
templates.Redirect(w, "/photo/private")
// Remove ALL notifications sent to ALL users who had access before.
models.RemoveNotification("__private_photos", currentUser.ID)
return
}
if user != nil && currentUser.ID == user.ID {
session.FlashError(w, r, "You cannot share your private photos with yourself.")
templates.Redirect(w, r.URL.Path)
return
}
// Any blocking?
if user != nil && models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
session.FlashError(w, r, "You are blocked from contacting this user.")
templates.Redirect(w, r.URL.Path)
return
}
// POSTing?
if r.Method == http.MethodPost {
var (
intent = r.PostFormValue("intent")
)
// If submitting, do it and redirect.
if intent == "submit" {
models.UnlockPrivatePhotos(currentUser.ID, user.ID)
session.Flash(w, r, "Your private photos have been unlocked for %s.", user.Username)
templates.Redirect(w, "/photo/private")
// Create a notification for this.
notif := &models.Notification{
UserID: user.ID,
AboutUser: *currentUser,
Type: models.NotificationPrivatePhoto,
TableName: "__private_photos",
TableID: currentUser.ID,
Link: fmt.Sprintf("/photo/u/%s", currentUser.Username),
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create PrivatePhoto notification: %s", err)
}
return
} else if intent == "revoke" {
models.RevokePrivatePhotos(currentUser.ID, user.ID)
session.Flash(w, r, "You have revoked access to your private photos for %s.", user.Username)
templates.Redirect(w, "/photo/private")
// Remove any notification we created when the grant was given.
models.RemoveSpecificNotification(user.ID, models.NotificationPrivatePhoto, "__private_photos", currentUser.ID)
return
}
// The other intent is "preview" so the user gets the confirmation
// screen before they continue, which shows the selected user info.
}
var vars = map[string]interface{}{
"User": user,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -12,11 +12,37 @@ import (
// SiteGallery controller (/photo/gallery) to view all members' public gallery pics.
func SiteGallery() http.HandlerFunc {
tmpl := templates.Must("photo/gallery.html")
// Whitelist for ordering options.
var sortWhitelist = []string{
"created_at desc",
"created_at asc",
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query params.
var (
viewStyle = r.FormValue("view") // cards (default), full
// Search filters.
filterExplicit = r.FormValue("explicit")
filterVisibility = r.FormValue("visibility")
sort = r.FormValue("sort")
sortOK bool
)
// Sort options.
for _, v := range sortWhitelist {
if sort == v {
sortOK = true
break
}
}
if !sortOK {
sort = "created_at desc"
}
// Defaults.
if viewStyle != "full" {
viewStyle = "cards"
}
@ -31,10 +57,10 @@ func SiteGallery() http.HandlerFunc {
pager := &models.Pagination{
Page: 1,
PerPage: config.PageSizeSiteGallery,
Sort: "created_at desc",
Sort: sort,
}
pager.ParsePage(r)
photos, err := models.PaginateGalleryPhotos(currentUser.ID, currentUser.IsAdmin, currentUser.Explicit, pager)
photos, err := models.PaginateGalleryPhotos(currentUser, filterExplicit, filterVisibility, pager)
// Bulk load the users associated with these photos.
var userIDs = []uint64{}
@ -62,6 +88,11 @@ func SiteGallery() http.HandlerFunc {
"CommentMap": commentMap,
"Pager": pager,
"ViewStyle": viewStyle,
// Search filters
"Sort": sort,
"FilterExplicit": filterExplicit,
"FilterVisibility": filterVisibility,
}
if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -62,9 +62,15 @@ func UserPhotos() http.HandlerFunc {
return
}
// Has this user granted access to see their privates?
var (
isGrantee = models.IsPrivateUnlocked(user.ID, currentUser.ID) // THEY have granted US access
isGranted = models.IsPrivateUnlocked(currentUser.ID, user.ID) // WE have granted THEM access
)
// What set of visibilities to query?
visibility := []models.PhotoVisibility{models.PhotoPublic}
if isOwnPhotos || currentUser.IsAdmin {
if isOwnPhotos || isGrantee || currentUser.IsAdmin {
visibility = append(visibility, models.PhotoFriends, models.PhotoPrivate)
} else if models.AreFriends(user.ID, currentUser.ID) {
visibility = append(visibility, models.PhotoFriends)
@ -100,15 +106,16 @@ func UserPhotos() http.HandlerFunc {
commentMap := models.MapCommentCounts("photos", photoIDs)
var vars = map[string]interface{}{
"IsOwnPhotos": currentUser.ID == user.ID,
"User": user,
"Photos": photos,
"PhotoCount": models.CountPhotos(user.ID),
"Pager": pager,
"LikeMap": likeMap,
"CommentMap": commentMap,
"ViewStyle": viewStyle,
"ExplicitCount": explicitCount,
"IsOwnPhotos": currentUser.ID == user.ID,
"IsMyPrivateUnlockedFor": isGranted, // have WE granted THIS USER to see our private pics?
"User": user,
"Photos": photos,
"PhotoCount": models.CountPhotos(user.ID),
"Pager": pager,
"LikeMap": likeMap,
"CommentMap": commentMap,
"ViewStyle": viewStyle,
"ExplicitCount": explicitCount,
}
if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -66,6 +66,13 @@ func View() http.HandlerFunc {
return
}
// Is this a private photo and are we allowed to see?
isGranted := models.IsPrivateUnlocked(user.ID, currentUser.ID)
if !isGranted && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
return
}
// Get Likes information about these photos.
likeMap := models.MapLikes(currentUser, "photos", []uint64{photo.ID})
commentMap := models.MapCommentCounts("photos", []uint64{photo.ID})

View File

@ -11,6 +11,7 @@ func AutoMigrate() {
DB.AutoMigrate(&User{})
DB.AutoMigrate(&ProfileField{})
DB.AutoMigrate(&Photo{})
DB.AutoMigrate(&PrivatePhoto{})
DB.AutoMigrate(&CertificationPhoto{})
DB.AutoMigrate(&Message{})
DB.AutoMigrate(&Friend{})

View File

@ -38,6 +38,7 @@ const (
NotificationAlsoPosted = "also_posted" // forum replies
NotificationCertRejected = "cert_rejected"
NotificationCertApproved = "cert_approved"
NotificationPrivatePhoto = "private_photo"
NotificationCustom = "custom" // custom message pushed
)
@ -62,6 +63,16 @@ func RemoveNotification(tableName string, tableID uint64) error {
return result.Error
}
// RemoveSpecificNotification to remove more specialized notifications where just removing by
// table name+ID is not adequate, e.g. for Private Photo Unlocks.
func RemoveSpecificNotification(userID uint64, t NotificationType, tableName string, tableID uint64) error {
result := DB.Where(
"user_id = ? AND type = ? AND table_name = ? AND table_id = ?",
userID, t, tableName, tableID,
).Delete(&Notification{})
return result.Error
}
// MarkNotificationsRead sets all a user's notifications to read.
func MarkNotificationsRead(user *User) error {
return DB.Model(&Notification{}).Where(

View File

@ -149,16 +149,26 @@ func CountExplicitPhotos(userID uint64, visibility []PhotoVisibility) (int64, er
return count, result.Error
}
// PaginateGalleryPhotos gets a page of all public user photos for the site gallery. Admin view
// returns ALL photos regardless of Gallery status.
func PaginateGalleryPhotos(userID uint64, adminView bool, explicitOK bool, pager *Pagination) ([]*Photo, error) {
/*
PaginateGalleryPhotos gets a page of all public user photos for the site gallery.
Admin view returns ALL photos regardless of Gallery status.
*/
func PaginateGalleryPhotos(user *User, filterExplicit, filterVisibility string, pager *Pagination) ([]*Photo, error) {
var (
p = []*Photo{}
query *gorm.DB
blocklist = BlockedUserIDs(userID)
friendIDs = FriendIDs(userID)
wheres = []string{}
placeholders = []interface{}{}
p = []*Photo{}
query *gorm.DB
// Get the user ID and their preferences.
userID = user.ID
adminView = user.IsAdmin // Admins see everything on the site.
explicitOK = user.Explicit // User opted-in for explicit content
blocklist = BlockedUserIDs(userID)
friendIDs = FriendIDs(userID)
privateUserIDs = PrivateGrantedUserIDs(userID)
wheres = []string{}
placeholders = []interface{}{}
)
// Include ourself in our friend IDs.
@ -166,10 +176,14 @@ func PaginateGalleryPhotos(userID uint64, adminView bool, explicitOK bool, pager
// You can see friends' Friend photos but only public for non-friends.
wheres = append(wheres,
"(user_id IN ? AND visibility IN ?) OR (user_id NOT IN ? AND visibility = ?)",
"((user_id IN ? AND visibility IN ?) OR "+
"(user_id IN ? AND visibility IN ?) OR "+
"(user_id NOT IN ? AND visibility = ?))",
)
placeholders = append(placeholders,
friendIDs, PhotoVisibilityFriends, friendIDs, PhotoPublic,
friendIDs, PhotoVisibilityFriends,
privateUserIDs, PhotoVisibilityAll,
friendIDs, PhotoPublic,
)
// Gallery photos only.
@ -182,12 +196,21 @@ func PaginateGalleryPhotos(userID uint64, adminView bool, explicitOK bool, pager
placeholders = append(placeholders, blocklist)
}
// Non-explicit pics unless the user opted in.
if !explicitOK {
// Non-explicit pics unless the user opted in. Allow explicit filter setting to override.
if filterExplicit != "" {
wheres = append(wheres, "explicit = ?")
placeholders = append(placeholders, filterExplicit == "true")
} else if !explicitOK {
wheres = append(wheres, "explicit = ?")
placeholders = append(placeholders, false)
}
// Is the user furthermore clamping the visibility filter?
if filterVisibility != "" {
wheres = append(wheres, "visibility = ?")
placeholders = append(placeholders, filterVisibility)
}
// Only certified user photos.
wheres = append(wheres,
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified = true)",
@ -201,6 +224,14 @@ func PaginateGalleryPhotos(userID uint64, adminView bool, explicitOK bool, pager
// Admin view: get ALL PHOTOS on the site, period.
if adminView {
query = DB
// Admin may filter too.
if filterVisibility != "" {
query = query.Where("visibility = ?", filterVisibility)
}
if filterExplicit != "" {
query = query.Where("explicit = ?", filterExplicit == "true")
}
} else {
query = DB.Where(
strings.Join(wheres, " AND "),

147
pkg/models/private_photo.go Normal file
View File

@ -0,0 +1,147 @@
package models
import (
"strings"
"time"
"gorm.io/gorm"
)
// PrivatePhoto table to track who you have unlocked your private photos for.
type PrivatePhoto struct {
ID uint64 `gorm:"primaryKey"`
SourceUserID uint64 `gorm:"index"` // the owner of a photo
TargetUserID uint64 `gorm:"index"` // the receiver
CreatedAt time.Time
UpdatedAt time.Time
}
// UnlockPrivatePhotos is sourceUserId allowing targetUserId to see their private photos.
func UnlockPrivatePhotos(sourceUserID, targetUserID uint64) error {
// Did we already allow this user?
var pb *PrivatePhoto
exist := DB.Where(
"source_user_id = ? AND target_user_id = ?",
sourceUserID, targetUserID,
).First(&pb).Error
// Update existing.
if exist == nil {
return nil
}
// Create the PrivatePhoto.
pb = &PrivatePhoto{
SourceUserID: sourceUserID,
TargetUserID: targetUserID,
}
return DB.Create(pb).Error
}
// RevokePrivatePhotos is sourceUserId revoking targetUserId to see their private photos.
func RevokePrivatePhotos(sourceUserID, targetUserID uint64) error {
result := DB.Where(
"source_user_id = ? AND target_user_id = ?",
sourceUserID, targetUserID,
).Delete(&PrivatePhoto{})
return result.Error
}
// RevokePrivatePhotosAll is sourceUserId revoking ALL USERS from their private photos.
func RevokePrivatePhotosAll(sourceUserID uint64) error {
result := DB.Where(
"source_user_id = ?",
sourceUserID,
).Delete(&PrivatePhoto{})
return result.Error
}
// IsPrivateUnlocked quickly sees if sourceUserID has unlocked private photos for targetUserID to see.
func IsPrivateUnlocked(sourceUserID, targetUserID uint64) bool {
pb := &PrivatePhoto{}
result := DB.Where(
"source_user_id = ? AND target_user_id = ?",
sourceUserID, targetUserID,
).First(&pb)
return result.Error == nil
}
// CountPrivateGrantee returns how many users have granted you access to their private photos.
func CountPrivateGrantee(userID uint64) int64 {
var count int64
DB.Model(&PrivatePhoto{}).Where(
"target_user_id = ?",
userID,
).Count(&count)
return count
}
// PrivateGrantedUserIDs returns all user IDs who have granted access for userId to see their private photos.
func PrivateGrantedUserIDs(userId uint64) []uint64 {
var (
ps = []*PrivatePhoto{}
userIDs = []uint64{userId}
)
DB.Where("target_user_id = ?", userId).Find(&ps)
for _, row := range ps {
userIDs = append(userIDs, row.SourceUserID)
}
return userIDs
}
/*
PaginatePrivatePhotoList views a user's list of private photo grants.
If grantee is true, it returns the list of users who have granted YOU access to see THEIR
private photos. If grantee is false, it returns the users that YOU have granted access to
see YOUR OWN private photos.
*/
func PaginatePrivatePhotoList(userID uint64, grantee bool, pager *Pagination) ([]*User, error) {
var (
pbs = []*PrivatePhoto{}
userIDs = []uint64{}
query *gorm.DB
wheres = []string{}
placeholders = []interface{}{}
)
// Which direction are we going?
if grantee {
// Return the private photo grants for whom YOU are the recipient.
wheres = append(wheres, "target_user_id = ?")
placeholders = append(placeholders, userID)
} else {
// Return the users that YOU have granted access to YOUR private pictures.
wheres = append(wheres, "source_user_id = ?")
placeholders = append(placeholders, userID)
}
query = DB.Where(
strings.Join(wheres, " AND "),
placeholders...,
)
query = query.Order(pager.Sort)
query.Model(&PrivatePhoto{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&pbs)
if result.Error != nil {
return nil, result.Error
}
// Now of these user IDs get their User objects.
for _, b := range pbs {
if grantee {
userIDs = append(userIDs, b.SourceUserID)
} else {
userIDs = append(userIDs, b.TargetUserID)
}
}
return GetUsers(userIDs)
}
// Save photo.
func (pb *PrivatePhoto) Save() error {
result := DB.Save(pb)
return result.Error
}

View File

@ -46,6 +46,8 @@ func New() http.Handler {
mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit()))
mux.Handle("/photo/delete", middleware.LoginRequired(photo.Delete()))
mux.Handle("/photo/certification", middleware.LoginRequired(photo.Certification()))
mux.Handle("/photo/private", middleware.LoginRequired(photo.Private()))
mux.Handle("/photo/private/share", middleware.LoginRequired(photo.Share()))
mux.Handle("/messages", middleware.LoginRequired(inbox.Inbox()))
mux.Handle("/messages/read/", middleware.LoginRequired(inbox.Inbox()))
mux.Handle("/messages/compose", middleware.LoginRequired(inbox.Compose()))

View File

@ -39,6 +39,12 @@
.has-text-private-light {
color: #FF99FF;
}
.hero.is-private {
background-color: #b748c7;
}
.hero.is-private.is-bold {
background-image: linear-gradient(141deg,#b329b1 0,#9948c7 71%,#7156d2 100%);
}
/* Mobile: notification badge near the hamburger menu */
.nonshy-mobile-notification {
@ -56,4 +62,9 @@
/* Bulma hack: full-width columns in photo card headers */
.nonshy-fullwidth {
width: 100%;
}
/* Collapsible cards for mobile (e.g. filter cards) */
.card.nonshy-collapsible-mobile {
cursor: pointer;
}

View File

@ -89,4 +89,41 @@ document.addEventListener('DOMContentLoaded', () => {
target.classList.remove("is-active");
});
});
// Collapsible cards for mobile view (e.g. People Search filters box)
(document.querySelectorAll(".card.nonshy-collapsible-mobile") || []).forEach(node => {
const header = node.querySelector(".card-header"),
body = node.querySelector(".card-content"),
icon = header.querySelector("button.card-header-icon > .icon > i");
// Icon classes.
const iconExpanded = "fa-angle-up",
iconContracted = "fa-angle-down";
// If we are already on mobile, hide the body now.
if (screen.width <= 768) {
body.style.display = "none";
if (icon !== null) {
icon.classList.remove(iconExpanded);
icon.classList.add(iconContracted);
}
}
// Add click toggle handler to the header.
header.addEventListener("click", () => {
if (body.style.display === "none") {
body.style.display = "block";
if (icon !== null) {
icon.classList.remove(iconContracted);
icon.classList.add(iconExpanded);
}
} else {
body.style.display = "none";
if (icon !== null) {
icon.classList.remove(iconExpanded);
icon.classList.add(iconContracted);
}
}
});
})
});

View File

@ -93,6 +93,13 @@
Upload Photos
</a>
</li>
<li>
<a href="/photo/private">
<span class="icon"><i class="fa fa-eye"></i></span>
Manage Private Photos
<span class="tag is-success ml-1">NEW!</span>
</a>
</li>
<li>
<a href="/settings">
<span class="icon"><i class="fa fa-edit"></i></span>
@ -250,6 +257,13 @@
<a href="/u/{{.AboutUser.Username}}"><strong>{{.AboutUser.Username}}</strong></a>
accepted your friend request!
</span>
{{else if eq .Type "private_photo"}}
<span class="icon"><i class="fa fa-unlock has-text-private"></i></span>
<span>
<a href="/u/{{.AboutUser.Username}}"><strong>{{.AboutUser.Username}}</strong></a>
has granted you access to see their
<a href="{{.Link}}" class="has-text-private">private photos</a>!
</span>
{{else if eq .Type "cert_approved"}}
<span class="icon"><i class="fa fa-certificate has-text-success"></i></span>
<span>

View File

@ -35,11 +35,16 @@
<div class="block">
<div class="card">
<header class="card-header">
<div class="card nonshy-collapsible-mobile">
<header class="card-header has-background-link-light">
<p class="card-header-title">
Search Filters
</p>
<button class="card-header-icon" type="button">
<span class="icon">
<i class="fa fa-angle-up"></i>
</span>
</button>
</header>
<div class="card-content">
<div class="columns is-multiline">

View File

@ -157,6 +157,10 @@
<span class="icon"><i class="fa fa-upload"></i></span>
<span>Upload Photo</span>
</a>
<a class="navbar-item" href="/photo/private">
<span class="icon"><i class="fa fa-eye"></i></span>
<span>Private Photos</span>
</a>
<a class="navbar-item" href="/settings">
<span class="icon"><i class="fa fa-gear"></i></span>
<span>Settings</span>

View File

@ -166,7 +166,7 @@
</div>
<div class="block">
<div class="level">
<div class="level{{if .IsOwnPhotos}}mb-0{{end}}">
<div class="level-left">
<div class="level-item">
<span>
@ -194,6 +194,102 @@
</div>
</div>
<div class="block">
<form action="/photo/gallery" method="GET">
<div class="card nonshy-collapsible-mobile">
<header class="card-header has-background-link-light">
<p class="card-header-title">
Search Filters <span class="tag is-success ml-2">NEW</span>
</p>
<button class="card-header-icon" type="button">
<span class="icon">
<i class="fa fa-angle-up"></i>
</span>
</button>
</header>
<div class="card-content">
<div class="columns is-multiline mb-0">
{{if .CurrentUser.Explicit}}
<div class="column">
<div class="field">
<label class="label" for="explicit">Explicit:</label>
<div class="select is-fullwidth">
<select id="explicit" name="explicit">
<option value="">Show all</option>
<option value="true"{{if eq .FilterExplicit "true"}} selected{{end}}>Only explicit</option>
<option value="false"{{if eq .FilterExplicit "false"}} selected{{end}}>Hide explicit</option>
</select>
</div>
</div>
</div>
{{end}}
<div class="column">
<div class="field">
<label class="label" for="visibility">Visibility:</label>
<div class="select is-fullwidth">
<select id="visibility" name="visibility">
<option value="">All photos</option>
<option value="public"{{if eq .FilterVisibility "public"}} selected{{end}}>Public only</option>
<option value="friends"{{if eq .FilterVisibility "friends"}} selected{{end}}>Friends only</option>
<option value="private"{{if eq .FilterVisibility "private"}} selected{{end}}>Private only</option>
</select>
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label" for="sort">Sort by:</label>
<div class="select is-fullwidth">
<select id="sort" name="sort">
<option value="created_at desc"{{if eq .Sort "created_at desc"}} selected{{end}}>Most recent</option>
<option value="created_at asc"{{if eq .Sort "created_at asc"}} selected{{end}}>Oldest first</option>
</select>
</div>
</div>
</div>
</div>
<div class="has-text-centered">
<a href="/photo/gallery" class="button">Reset</a>
<button type="submit" class="button is-success">
Apply Filters
</button>
</div>
</div>
</div>
</form>
</div>
{{if .IsOwnPhotos}}
<div class="block">
<a href="/photo/private" class="has-text-private">
<span class="icon"><i class="fa fa-lock"></i></span>
<span>Manage who can see <strong>my</strong> private photos</span>
<span class="tag is-success">NEW</span>
</a>
</div>
{{else if and (not .IsSiteGallery) (not .IsMyPrivateUnlockedFor)}}
<div class="block">
<a href="/photo/private/share?to={{.User.Username}}" class="has-text-private">
<span class="icon"><i class="fa fa-unlock"></i></span>
<span>Grant <strong>{{.User.Username}}</strong> access to see <strong>my</strong> private photos</span>
<span class="tag is-success">NEW</span>
</a>
</div>
{{else if and (not .IsSiteGallery) .IsMyPrivateUnlockedFor}}
<div class="block">
<span class="icon"><i class="fa fa-unlock has-text-private"></i></span>
<span>You had granted <strong>{{.User.Username}}</strong> access to see <strong>your</strong> private photos.</span>
<a href="/photo/private">Manage that here.</a>
<span class="tag is-success">NEW</span>
</div>
{{end}}
{{template "pager" .}}
<!-- "Full" view style? (blog style) -->

View File

@ -0,0 +1,157 @@
{{define "title"}}Private Photos{{end}}
{{define "content"}}
<div class="container">
{{$Root := .}}
<section class="hero is-private is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title has-text-light">
<i class="fa fa-eye mr-2"></i>
Private Photos
</h1>
</div>
</div>
</section>
<div class="p-4 is-text-centered">
<div class="level">
<div class="level-item">
<div class="tabs is-toggle">
<ul>
<li{{if not .IsGrantee}} class="is-active"{{end}}>
<a href="/photo/private">My Shares</a>
</li>
<li{{if .IsGrantee}} class="is-active"{{end}}>
<a href="/photo/private?view=grantee">
Shared With Me
{{if .CountGrantee}}
<span class="tag is-grey ml-2">{{.CountGrantee}}</span>
{{end}}
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="p-4">
<div class="block">
{{if .IsGrantee}}
{{.Pager.Total}} member{{Pluralize64 .Pager.Total}}
{{Pluralize64 .Pager.Total "has" "have"}}
granted you access to see their private photos
{{else}}
You have granted access to {{.Pager.Total}} member{{Pluralize64 .Pager.Total}}
to see your private photos
{{end}}
(page {{.Pager.Page}} of {{.Pager.Pages}}).
</div>
{{if not .IsGrantee}}
<div class="columns is-gapless is-centered">
<div class="column is-narrow mx-1 my-2">
<a href="/photo/private/share" class="button is-primary is-outlined is-fullwidth">
<span class="icon"><i class="fa fa-plus"></i></span>
<span>Add new share</span>
</a>
</div>
<div class="column is-narrow mx-1 my-2">
<a href="/photo/private/share?intent=revoke-all" class="button is-danger is-outlined is-fullwidth"
onclick="return confirm('Are you sure you want to lock your Private Photos from ALL users?')">
<span class="icon"><i class="fa fa-lock"></i></span>
<span>Revoke ALL Shares</span>
</a>
</div>
</div>
{{end}}
<div class="block">
<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}}?{{if .IsGrantee}}view=grantee&{{end}}page={{.Pager.Previous}}">Previous</a>
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
href="{{.Request.URL.Path}}?{{if .IsGrantee}}view=grantee&{{end}}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}}?{{if $Root.IsGrantee}}view=grantee&{{end}}page={{.Page}}">
{{.Page}}
</a>
</li>
{{end}}
</ul>
</nav>
</div>
<div class="columns is-multiline">
{{range .Users}}
<div class="column is-half-tablet is-one-third-desktop">
<form action="/photo/private/share" method="POST">
{{InputCSRF}}
<input type="hidden" name="to" value="{{.Username}}">
<div class="card">
<div class="card-content">
<div class="media block">
<div class="media-left">
<figure class="image is-64x64">
<a href="/u/{{.Username}}">
{{if .ProfilePhoto.ID}}
<img src="{{PhotoURL .ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</a>
</figure>
</div>
<div class="media-content">
<p class="title is-4">
<a href="/u/{{.Username}}" class="has-text-dark">{{.NameOrUsername}}</a>
</p>
<p class="subtitle is-6">
<span class="icon"><i class="fa fa-user"></i></span>
<a href="/u/{{.Username}}">{{.Username}}</a>
{{if not .Certified}}
<span class="has-text-danger">
<span class="icon"><i class="fa fa-certificate"></i></span>
<span>Not Certified!</span>
</span>
{{end}}
{{if .IsAdmin}}
<span class="has-text-danger">
<span class="icon"><i class="fa fa-gavel"></i></span>
<span>Admin</span>
</span>
{{end}}
</p>
</div>
</div>
</div>
{{if not $Root.IsGrantee}}
<footer class="card-footer">
<button type="submit" name="intent" value="revoke" class="card-footer-item button is-danger is-outlined"
onclick="return confirm('Are you sure you want to revoke private photo access to this user?')">
<span class="icon"><i class="fa fa-xmark"></i></span>
<span>Revoke Access</span>
</button>
</footer>
{{end}}
</div>
</form>
</div>
{{end}}<!-- range .Friends -->
</div>
</div>
</div>
{{end}}

View File

@ -0,0 +1,117 @@
{{define "title"}}Share Private Photos{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-info is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
Share Private Photos
</h1>
</div>
</div>
</section>
<div class="block p-4">
<div class="columns is-centered">
<div class="column is-half">
<div class="card" style="width: 100%; max-width: 640px">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
<span class="icon mr-2"><i class="fa fa-eye"></i></span>
<span>Share Private Photos</span>
</p>
</header>
<div class="card-content">
<!-- Show the introduction or selected user profile -->
{{if not .User}}
<div class="block">
You may use this page to grant access to your
<span class="has-text-private">
<i class="fa fa-eye"></i>
Private Photos
</span>
to another member on this site by entering their
username below.
</div>
{{else}}
<div class="block">
Confirm that you wish to grant <strong>{{.User.Username}}</strong>
access to view your
<span class="has-text-private">
<i class="fa fa-eye"></i>
Private Photos
</span>
by clicking the button below.
</div>
<div class="media block">
<div class="media-left">
<figure class="image is-64x64">
{{if .User.ProfilePhoto.ID}}
<img src="{{PhotoURL .User.ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
</div>
<div class="media-content">
<p class="title is-4">{{.NameOrUsername}}</p>
<p class="subtitle is-6">
<span class="icon"><i class="fa fa-user"></i></span>
<a href="/u/{{.User.Username}}" target="_blank">{{.User.Username}}</a>
</p>
</div>
</div>
{{end}}
<!-- Show the relevant form -->
<form action="/photo/private/share" method="POST">
{{InputCSRF}}
{{if .User}}
<input type="hidden" name="to" value="{{.User.Username}}">
{{else}}
<div class="field block">
<label for="to" class="label">Share with Username:</label>
<input type="text" class="input"
name="to" id="to"
placeholder="username">
</div>
{{end}}
<div class="field has-text-centered">
<button type="submit" class="button is-success"
name="intent"
value="{{if .User}}submit{{else}}preview{{end}}">
{{if .User}}
<span class="icon"><i class="fa fa-unlock"></i></span>
<span>Confirm Share</span>
{{else}}
Continue
{{end}}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
window.addEventListener("DOMContentLoaded", (event) => {
let $file = document.querySelector("#file"),
$fileName = document.querySelector("#fileName");
$file.addEventListener("change", function() {
let file = this.files[0];
$fileName.innerHTML = file.name;
});
});
</script>
{{end}}

View File

@ -214,7 +214,7 @@
name="visibility"
value="public"
{{if or (not .EditPhoto) (eq .EditPhoto.Visibility "public")}}checked{{end}}>
<strong class="has-text-link">
<strong class="has-text-link ml-1">
<span>Public</span>
<span class="icon"><i class="fa fa-eye"></i></span>
</strong>
@ -231,7 +231,7 @@
name="visibility"
value="friends"
{{if eq .EditPhoto.Visibility "friends"}}checked{{end}}>
<strong class="has-text-warning-dark">
<strong class="has-text-warning-dark ml-1">
<span>Friends only</span>
<span class="icon"><i class="fa fa-user-group"></i></span>
</strong>
@ -248,7 +248,7 @@
name="visibility"
value="private"
{{if eq .EditPhoto.Visibility "private"}}checked{{end}}>
<strong class="has-text-private">
<strong class="has-text-private ml-1">
<span>Private</span>
<span class="icon"><i class="fa fa-lock"></i></span>
</strong>
@ -258,6 +258,15 @@
granted access (the latter feature is coming soon!)
</p>
</div>
</div>
<div class="notification is-warning is-light p-2 is-size-7">
<i class="fa fa-warning"></i> <strong>Notice:</strong> the square cropped
thumbnail of your Default Profile Picture will always be visible on your
profile and displayed alongside your username elsewhere on the site. The above Visibility
setting <em>can</em> limit the full-size photo's visibility; but the square cropped
thumbnail is always seen to logged-in members.
</div>
<div class="field mb-5">