Comments on Photos

* Add permalink URL for photos to view their comment threads.
* Commenters can Edit or Delete their own comments.
* Photo owners can Delete any comment on it.
* Update Privacy Policy
pull/12/head
Noah 2022-08-26 19:50:33 -07:00
parent 0690a9a5b0
commit c1268ae9b1
14 changed files with 830 additions and 41 deletions

View File

@ -0,0 +1,192 @@
package comment
import (
"net/http"
"net/url"
"strconv"
"strings"
"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"
)
// PostComment view - for previewing or submitting your comment.
func PostComment() http.HandlerFunc {
tmpl := templates.Must("comment/post_comment.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query params.
var (
tableName = r.FormValue("table_name")
tableID uint64
editCommentID = r.FormValue("edit") // edit your comment
isDelete = r.FormValue("delete") == "true"
intent = r.FormValue("intent") // preview or submit
message = r.PostFormValue("message") // comment body
comment *models.Comment // if editing a comment
fromURL = r.FormValue("next") // what page to send back to
)
// Parse the table ID param.
if idStr := r.FormValue("table_id"); idStr == "" {
session.FlashError(w, r, "Comment table ID required.")
templates.Redirect(w, "/")
return
} else {
if idInt, err := strconv.Atoi(idStr); err != nil {
session.FlashError(w, r, "Comment table ID invalid.")
templates.Redirect(w, "/")
return
} else {
tableID = uint64(idInt)
}
}
// Redirect URL must be relative.
if !strings.HasPrefix(fromURL, "/") {
// Maybe it's URL encoded?
fromURL, _ = url.QueryUnescape(fromURL)
if !strings.HasPrefix(fromURL, "/") {
fromURL = "/"
}
}
// Validate everything else.
if _, ok := models.CommentableTables[tableName]; !ok {
session.FlashError(w, r, "You can not comment on that.")
templates.Redirect(w, "/")
return
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get current user: %s", err)
templates.Redirect(w, "/")
return
}
// Who will we notify about this comment? e.g. if commenting on a photo,
// this is the user who owns the photo.
var notifyUser *models.User
switch tableName {
case "photos":
if photo, err := models.GetPhoto(tableID); err == nil {
if user, err := models.GetUser(photo.UserID); err == nil {
notifyUser = user
} else {
log.Error("Comments: couldn't get NotifyUser for photo ID %d (user ID %d): %s",
tableID, photo.UserID, err,
)
}
} else {
log.Error("Comments: couldn't get NotifyUser for photo ID %d: %s", tableID, err)
}
}
// Are we editing or deleting our comment?
if len(editCommentID) > 0 {
if i, err := strconv.Atoi(editCommentID); err == nil {
if found, err := models.GetComment(uint64(i)); err == nil {
comment = found
// Verify that it is indeed OUR comment to manage:
// - If the current user posted it
// - If we are an admin
// - If we are the notifyUser for this comment (they can delete, not edit).
if currentUser.ID != comment.UserID && !currentUser.IsAdmin &&
!(notifyUser != nil && currentUser.ID == notifyUser.ID && isDelete) {
templates.ForbiddenPage(w, r)
return
}
// Initialize the form w/ the content of this message.
if r.Method == http.MethodGet {
message = comment.Message
}
// Are we DELETING this comment?
if isDelete {
if err := comment.Delete(); err != nil {
session.FlashError(w, r, "Error deleting your commenting: %s", err)
} else {
session.Flash(w, r, "Your comment has been deleted.")
}
templates.Redirect(w, fromURL)
return
}
} else {
// Comment not found - show the Forbidden page anyway.
templates.ForbiddenPage(w, r)
return
}
} else {
templates.NotFoundPage(w, r)
return
}
}
// Submitting the form.
if r.Method == http.MethodPost {
// Default intent is preview unless told to submit.
if intent == "submit" {
// Are we modifying an existing comment?
if comment != nil {
comment.Message = message
if err := comment.Save(); err != nil {
session.FlashError(w, r, "Couldn't save comment: %s", err)
} else {
session.Flash(w, r, "Comment updated!")
}
templates.Redirect(w, fromURL)
return
}
// Create the comment.
if comment, err := models.AddComment(
currentUser,
tableName,
tableID,
message,
); err != nil {
session.FlashError(w, r, "Couldn't create comment: %s", err)
} else {
session.Flash(w, r, "Comment added!")
templates.Redirect(w, fromURL)
// Notify the recipient of the comment.
if notifyUser != nil && notifyUser.ID != currentUser.ID {
notif := &models.Notification{
UserID: notifyUser.ID,
AboutUser: *currentUser,
Type: models.NotificationComment,
TableName: comment.TableName,
TableID: comment.TableID,
Message: message,
Link: fromURL,
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create Comment notification: %s", err)
}
}
return
}
}
}
var vars = map[string]interface{}{
"Intent": intent,
"EditCommentID": editCommentID,
"Message": message,
"TableName": tableName,
"TableID": tableID,
"Next": fromURL,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -52,12 +52,14 @@ func SiteGallery() http.HandlerFunc {
photoIDs = append(photoIDs, p.ID)
}
likeMap := models.MapLikes(currentUser, "photos", photoIDs)
commentMap := models.MapCommentCounts("photos", photoIDs)
var vars = map[string]interface{}{
"IsSiteGallery": true,
"Photos": photos,
"UserMap": userMap,
"LikeMap": likeMap,
"CommentMap": commentMap,
"Pager": pager,
"ViewStyle": viewStyle,
}

View File

@ -97,6 +97,7 @@ func UserPhotos() http.HandlerFunc {
photoIDs = append(photoIDs, p.ID)
}
likeMap := models.MapLikes(currentUser, "photos", photoIDs)
commentMap := models.MapCommentCounts("photos", photoIDs)
var vars = map[string]interface{}{
"IsOwnPhotos": currentUser.ID == user.ID,
@ -105,6 +106,7 @@ func UserPhotos() http.HandlerFunc {
"PhotoCount": models.CountPhotos(user.ID),
"Pager": pager,
"LikeMap": likeMap,
"CommentMap": commentMap,
"ViewStyle": viewStyle,
"ExplicitCount": explicitCount,
}

View File

@ -0,0 +1,92 @@
package photo
import (
"net/http"
"strconv"
"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"
)
// View photo controller to see the comment thread.
func View() http.HandlerFunc {
tmpl := templates.Must("photo/permalink.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Required query param: the photo ID.
var photo *models.Photo
if idStr := r.FormValue("id"); idStr == "" {
session.FlashError(w, r, "Missing photo ID parameter.")
templates.Redirect(w, "/")
return
} else {
if idInt, err := strconv.Atoi(idStr); err != nil {
session.FlashError(w, r, "Invalid ID parameter.")
templates.Redirect(w, "/")
return
} else {
if found, err := models.GetPhoto(uint64(idInt)); err != nil {
templates.NotFoundPage(w, r)
return
} else {
photo = found
}
}
}
// Find the photo's owner.
user, err := models.GetUser(photo.UserID)
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 isOwnPhoto = currentUser.ID == user.ID
// Is either one blocking?
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
return
}
// Is this user private and we're not friends?
var (
areFriends = models.AreFriends(user.ID, currentUser.ID)
isPrivate = user.Visibility == models.UserVisibilityPrivate && !areFriends
)
if isPrivate && !currentUser.IsAdmin && !isOwnPhoto {
session.FlashError(w, r, "This user's profile page and photo gallery are private.")
templates.Redirect(w, "/u/"+user.Username)
return
}
// Get Likes information about these photos.
likeMap := models.MapLikes(currentUser, "photos", []uint64{photo.ID})
commentMap := models.MapCommentCounts("photos", []uint64{photo.ID})
// Get all the comments.
comments, err := models.ListComments("photos", photo.ID)
if err != nil {
log.Error("Couldn't list comments for photo %d: %s", photo.ID, err)
}
var vars = map[string]interface{}{
"IsOwnPhoto": currentUser.ID == user.ID,
"User": user,
"Photo": photo,
"LikeMap": likeMap,
"CommentMap": commentMap,
"Comments": comments,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -3,6 +3,7 @@ package models
import (
"time"
"code.nonshy.com/nonshy/website/pkg/log"
"gorm.io/gorm"
)
@ -18,6 +19,12 @@ type Comment struct {
UpdatedAt time.Time
}
// CommentableTables are the set of table names that allow comments (via the
// generic "/comments" URI which accepts a table_name param)
var CommentableTables = map[string]interface{}{
"photos": nil,
}
// Preload related tables for the forum (classmethod).
func (c *Comment) Preload() *gorm.DB {
return DB.Preload("User.ProfilePhoto")
@ -74,6 +81,16 @@ func PaginateComments(user *User, tableName string, tableID uint64, pager *Pagin
return cs, result.Error
}
// ListComments returns a complete set of comments without paging.
func ListComments(tableName string, tableID uint64) ([]*Comment, error) {
var cs []*Comment
result := (&Comment{}).Preload().Where(
"table_name = ? AND table_id = ?",
tableName, tableID,
).Order("created_at asc").Find(&cs)
return cs, result.Error
}
// Save a comment.
func (c *Comment) Save() error {
return DB.Save(c).Error
@ -83,3 +100,50 @@ func (c *Comment) Save() error {
func (c *Comment) Delete() error {
return DB.Delete(c).Error
}
type CommentCountMap map[uint64]int64
// MapCommentCounts collects total numbers of comments over a set of table IDs. Returns a
// map of table ID (uint64) to comment counts for each (int64).
func MapCommentCounts(tableName string, tableIDs []uint64) CommentCountMap {
var result = CommentCountMap{}
// Initialize the result set.
for _, id := range tableIDs {
result[id] = 0
}
// Hold the result of the grouped count query.
type group struct {
ID uint64
Comments int64
}
var groups = []group{}
// Map the counts of comments to each of these IDs.
if res := DB.Table(
"comments",
).Select(
"table_id AS id, count(id) AS comments",
).Where(
"table_name = ? AND table_id IN ?",
tableName, tableIDs,
).Group("table_id").Scan(&groups); res.Error != nil {
log.Error("MapCommentCounts: count query: %s", res.Error)
}
// Map the counts back in.
for _, row := range groups {
result[row.ID] = row.Comments
}
return result
}
// Get a comment count for the given table ID from the map.
func (cc CommentCountMap) Get(id uint64) int64 {
if value, ok := cc[id]; ok {
return value
}
return 0
}

View File

@ -18,6 +18,7 @@ type Notification struct {
TableName string // on which of your tables (photos, comments, ...)
TableID uint64
Message string // text associated, e.g. copy of comment added
Link string // associated URL, e.g. for comments
CreatedAt time.Time
UpdatedAt time.Time
}
@ -148,8 +149,8 @@ func MapNotifications(ns []*Notification) NotificationMap {
"notifications.id IN ?",
IDs,
).Scan(&scan)
if err != nil {
log.Error("Couldn't select photo IDs for notifications: %s", err)
if err.Error != nil {
log.Error("Couldn't select photo IDs for notifications: %s", err.Error)
}
// Collect and load all the photos by ID.

View File

@ -9,6 +9,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/controller/admin"
"code.nonshy.com/nonshy/website/pkg/controller/api"
"code.nonshy.com/nonshy/website/pkg/controller/block"
"code.nonshy.com/nonshy/website/pkg/controller/comment"
"code.nonshy.com/nonshy/website/pkg/controller/forum"
"code.nonshy.com/nonshy/website/pkg/controller/friend"
"code.nonshy.com/nonshy/website/pkg/controller/inbox"
@ -41,6 +42,7 @@ func New() http.Handler {
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/view", middleware.LoginRequired(photo.View()))
mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit()))
mux.Handle("/photo/delete", middleware.LoginRequired(photo.Delete()))
mux.Handle("/photo/certification", middleware.LoginRequired(photo.Certification()))
@ -51,6 +53,7 @@ func New() http.Handler {
mux.Handle("/friends/add", middleware.LoginRequired(friend.AddFriend()))
mux.Handle("/users/block", middleware.LoginRequired(block.BlockUser()))
mux.Handle("/users/blocked", middleware.LoginRequired(block.Blocked()))
mux.Handle("/comments", middleware.LoginRequired(comment.PostComment()))
mux.Handle("/admin/unimpersonate", middleware.LoginRequired(admin.Unimpersonate()))
// Certification Required. Pages that only full (verified) members can access.

View File

@ -4,6 +4,7 @@ import (
"fmt"
"html/template"
"net/http"
"net/url"
"strings"
"time"
@ -74,6 +75,13 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
"SubtractInt": func(a, b int) int {
return a - b
},
"UrlEncode": func(values ...interface{}) string {
var result string
for _, value := range values {
result += url.QueryEscape(fmt.Sprintf("%v", value))
}
return result
},
}
}

View File

@ -194,15 +194,32 @@
<a href="/u/{{.AboutUser.Username}}"><strong>{{.AboutUser.Username}}</strong></a>
liked your
{{if eq .TableName "photos"}}
photo.
{{if $Body.Photo}}
<a href="/photo/view?id={{$Body.Photo.ID}}">photo</a>.
{{else}}
photo.
{{end}}
{{else if eq .TableName "users"}}
profile page.
{{else}}
{{.TableName}}.
{{end}}
</span>
{{else if eq .Type "comment"}}
<span class="icon"><i class="fa fa-comment has-text-success"></i></span>
<span>
<a href="/u/{{.AboutUser.Username}}"><strong>{{.AboutUser.Username}}</strong></a>
commented on your
<a href="{{.Link}}">
{{if eq .TableName "photos"}}
photo:
{{else}}
{{.TableName}}:
{{end}}
</a>
</span>
{{else if eq .Type "friendship_approved"}}
<span class="icon"><i class="fa fa-user-group"></i></span>
<span class="icon"><i class="fa fa-user-group has-text-success"></i></span>
<span>
<a href="/u/{{.AboutUser.Username}}"><strong>{{.AboutUser.Username}}</strong></a>
accepted your friend request!
@ -224,7 +241,7 @@
<!-- Attached message? -->
{{if .Message}}
<div class="block content">
<div class="block content mb-1">
{{ToMarkdown .Message}}
</div>
{{end}}
@ -232,7 +249,15 @@
<!-- Photo caption? -->
{{if $Body.Photo}}
<div class="block">
<em>{{or $Body.Photo.Caption "No caption."}}</em>
<!-- If it's a comment, have a link to view it -->
{{if eq .Type "comment"}}
<div class="is-size-7 pt-1">
<span class="icon"><i class="fa fa-arrow-right"></i></span>
<a href="{{.Link}}">See all comments</a>
</div>
{{else}}
<em>{{or $Body.Photo.Caption "No caption."}}</em>
{{end}}
</div>
{{end}}
@ -245,7 +270,9 @@
<!-- Attached photo? -->
{{if $Body.PhotoID}}
<div class="column is-one-quarter">
<img src="{{PhotoURL $Body.Photo.Filename}}">
<a href="/photo/view?id={{$Body.Photo.ID}}">
<img src="{{PhotoURL $Body.Photo.Filename}}">
</a>
{{if $Body.Photo.Caption}}
<small>{{$Body.Photo.Caption}}</small>

View File

@ -0,0 +1,91 @@
{{define "title"}}
{{if .EditCommentID}}
Edit Comment
{{else}}
New Comment
{{end}}
{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-info is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
{{if .EditCommentID}}
Edit Comment
{{else}}
Add Comment
{{end}}
</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"><i class="fa fa-message"></i></span>
{{if .EditCommentID}}
Edit Comment
{{else}}
New Comment
{{end}}
</p>
</header>
<div class="card-content">
{{if and (eq .Request.Method "POST") (ne .Message "")}}
<label class="label">Preview:</label>
<div class="box content has-background-warning-light">
{{ToMarkdown .Message}}
</div>
{{end}}
<form action="{{.Request.URL.Path}}" method="POST">
{{InputCSRF}}
<input type="hidden" name="table_name" value="{{.TableName}}">
<input type="hidden" name="table_id" value="{{.TableID}}">
<input type="hidden" name="next" value="{{.Next}}">
<input type="hidden" name="edit" value="{{.EditCommentID}}">
<div class="field block">
<label for="message" class="label">Message</label>
<textarea class="textarea" cols="80" rows="8"
name="message"
id="message"
required
placeholder="Message">{{.Message}}</textarea>
<p class="help">
Markdown formatting supported.
</p>
</div>
<div class="field has-text-centered">
<button type="submit"
name="intent"
value="preview"
class="button is-link">
Preview
</button>
<button type="submit"
name="intent"
value="submit"
class="button is-success">
Post Message
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}

View File

@ -127,6 +127,14 @@
<div class="column content">
{{ToMarkdown .Message}}
{{if .UpdatedAt.After .CreatedAt}}
<div class="mt-4">
<em title="{{.UpdatedAt.Format "2006-01-02 15:04:05"}}">
<small>Edited {{SincePrettyCoarse .UpdatedAt}} ago</small>
</em>
</div>
{{end}}
<hr class="has-background-grey mb-2">
<div class="columns is-mobile is-multiline is-size-7 mb-0">

View File

@ -263,20 +263,29 @@
{{template "card-body" .}}
<!-- Like button -->
<div class="mt-4">
{{$Like := $Root.LikeMap.Get .ID}}
<button type="button" class="button is-small nonshy-like-button"
data-table-name="photos" data-table-id="{{.ID}}"
title="Like">
<span class="icon{{if $Like.UserLikes}} has-text-danger{{end}}"><i class="fa fa-heart"></i></span>
<span class="nonshy-likes">
Like
{{if gt $Like.Count 0}}
({{$Like.Count}})
{{end}}
</span>
</button>
<!-- Like & Comments buttons -->
<div class="mt-4 columns is-centered is-mobile is-gapless">
<div class="column is-narrow mr-1">
{{$Like := $Root.LikeMap.Get .ID}}
<button type="button" class="button is-small nonshy-like-button"
data-table-name="photos" data-table-id="{{.ID}}"
title="Like">
<span class="icon{{if $Like.UserLikes}} has-text-danger{{end}}"><i class="fa fa-heart"></i></span>
<span class="nonshy-likes">
Like
{{if gt $Like.Count 0}}
({{$Like.Count}})
{{end}}
</span>
</button>
</div>
<div class="column is-narrow">
{{$Comments := $Root.CommentMap.Get .ID}}
<a href="/photo/view?id={{.ID}}#comments" class="button is-small">
<span class="icon"><i class="fa fa-comment"></i></span>
<span>{{$Comments}} Comment{{Pluralize64 $Comments}}</span>
</a>
</div>
</div>
</div>
@ -360,20 +369,29 @@
{{template "card-body" .}}
<!-- Like button -->
<div class="mt-4">
{{$Like := $Root.LikeMap.Get .ID}}
<button type="button" class="button is-small nonshy-like-button"
data-table-name="photos" data-table-id="{{.ID}}"
title="Like">
<span class="icon{{if $Like.UserLikes}} has-text-danger{{end}}"><i class="fa fa-heart"></i></span>
<span class="nonshy-likes">
Like
{{if gt $Like.Count 0}}
({{$Like.Count}})
{{end}}
</span>
</button>
<!-- Like & Comments buttons -->
<div class="mt-4 columns is-centered is-mobile is-gapless">
<div class="column is-narrow mr-1">
{{$Like := $Root.LikeMap.Get .ID}}
<button type="button" class="button is-small nonshy-like-button"
data-table-name="photos" data-table-id="{{.ID}}"
title="Like">
<span class="icon{{if $Like.UserLikes}} has-text-danger{{end}}"><i class="fa fa-heart"></i></span>
<span class="nonshy-likes">
Like
{{if gt $Like.Count 0}}
({{$Like.Count}})
{{end}}
</span>
</button>
</div>
<div class="column is-narrow">
{{$Comments := $Root.CommentMap.Get .ID}}
<a href="/photo/view?id={{.ID}}#comments" class="button is-small">
<span class="icon"><i class="fa fa-comment"></i></span>
<span>{{$Comments}} Comment{{Pluralize64 $Comments}}</span>
</a>
</div>
</div>
</div>
@ -385,7 +403,7 @@
{{if not $Root.IsOwnPhotos}}
<a class="card-footer-item has-text-danger" href="/contact?intent=report&subject=report.photo&id={{.ID}}">
<span class="icon"><i class="fa fa-flag"></i></span>
<span>Report</span>
<span class="is-hidden-desktop">Report</span>
</a>
{{end}}
</footer>

View File

@ -0,0 +1,264 @@
{{define "title"}}Upload a Photo{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-info is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
<span class="icon mr-4">
<i class="fa fa-image"></i>
</span>
<span>{{or .Photo.Caption "Photo"}}</span>
</h1>
</div>
</div>
</section>
{{ $Root := . }}
{{ $User := .CurrentUser }}
{{ $Comments := .CommentMap.Get .Photo.ID }}
<div class="block p-4">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<a href="/u/{{.User.Username}}">
<span class="icon"><i class="fa fa-user"></i></span>
<span>{{.User.Username}}</span>
</a>
</li>
<li>
<a href="/photo/u/{{.User.Username}}">Photos</a>
</li>
<li class="is-active">
<a href="{{.Request.URL.Path}}" aria-current="page">Comments</a>
</li>
</ul>
</nav>
</div>
<div class="block p-4">
<!-- Photo Card -->
<div class="card block">
<header class="card-header {{if .Photo.Explicit}}has-background-danger{{else}}has-background-link{{end}}">
<div class="card-header-title has-text-light">
<div class="columns is-mobile is-gapless nonshy-fullwidth">
<div class="column is-narrow">
<figure class="image is-24x24 mr-2">
{{if gt .User.ProfilePhoto.ID 0}}
<img src="{{PhotoURL .User.ProfilePhoto.CroppedFilename}}" class="is-rounded">
{{else}}
<img src="/static/img/shy.png" class="is-rounded has-background-warning">
{{end}}
</figure>
</div>
<div class="column">
<a href="/u/{{.User.Username}}" class="has-text-light">
{{.User.Username}}
<i class="fa fa-external-link ml-2"></i>
</a>
</div>
<div class="column is-narrow">
<span class="icon">
{{if eq .Photo.Visibility "friends"}}
<i class="fa fa-user-group has-text-warning" title="Friends"></i>
{{else if eq .Photo.Visibility "private"}}
<i class="fa fa-lock has-text-private-light" title="Private"></i>
{{else}}
<i class="fa fa-eye has-text-link-light" title="Public"></i>
{{end}}
</span>
</div>
</div>
</div>
</header>
<div class="card-image">
<figure class="image">
<img src="{{PhotoURL .Photo.Filename}}">
</figure>
</div>
<div class="card-content">
{{if .Photo.Caption}}
{{.Photo.Caption}}
{{else}}<em>No caption</em>{{end}}
<!-- Like & Comments buttons -->
<div class="mt-4 mb-2 columns is-centered is-mobile is-gapless">
<div class="column is-narrow mr-2">
{{$Like := .LikeMap.Get .Photo.ID}}
<button type="button" class="button is-small nonshy-like-button"
data-table-name="photos" data-table-id="{{.Photo.ID}}"
title="Like">
<span class="icon{{if $Like.UserLikes}} has-text-danger{{end}}"><i class="fa fa-heart"></i></span>
<span class="nonshy-likes">
Like
{{if gt $Like.Count 0}}
({{$Like.Count}})
{{end}}
</span>
</button>
</div>
<div class="column is-narrow">
<a href="/photo/view?id={{.Photo.ID}}#comments" class="button is-small">
<span class="icon"><i class="fa fa-comment"></i></span>
<span>{{$Comments}} Comment{{Pluralize64 $Comments}}</span>
</a>
</div>
</div>
<!-- Photo controls buttons (edit/delete/report) -->
<div class="my-0 columns is-centered is-mobile is-gapless">
<!-- Owned photo: have edit/delete buttons too -->
{{if or .IsOwnPhoto .CurrentUser.IsAdmin}}
<div class="column is-narrow">
<a href="/photo/edit?id={{.Photo.ID}}" class="button is-small">
<span class="icon"><i class="fa fa-edit"></i></span>
<span>Edit</span>
</a>
</div>
<div class="column is-narrow ml-2">
<a href="/photo/delete?id={{.Photo.ID}}" class="button is-small has-text-danger">
<span class="icon"><i class="fa fa-trash"></i></span>
<span>Delete</span>
</a>
</div>
{{end}}
<!-- Report button except on your own pic -->
{{if not .IsOwnPhoto}}
<div class="column is-narrow ml-2">
<a href="/contact?intent=report&subject=report.photo&id={{.Photo.ID}}" class="button is-small has-text-danger">
<span class="icon"><i class="fa fa-flag"></i></span>
<span>Report</span>
</a>
</div>
{{end}}
</div>
</div>
</div><!-- /photo card -->
<!-- Comments Card -->
<div class="card" id="comments">
<header class="card-header has-background-success">
<p class="card-header-title has-text-light">
<span class="icon mr-2"><i class="fa fa-comment"></i></span>
<span>{{$Comments}} Comment{{Pluralize64 $Comments}}</span>
</p>
</header>
<div class="card-content">
<form action="/comments" method="POST">
{{InputCSRF}}
<input type="hidden" name="table_name" value="photos">
<input type="hidden" name="table_id" value="{{.Photo.ID}}">
<input type="hidden" name="next" value="{{.Request.URL.Path}}?id={{.Photo.ID}}">
<div class="field">
<label for="message">Add your comment</label>
<textarea class="textarea" cols="80" rows="4"
name="message" id="message"
placeholder="Add your comment"></textarea>
<p class="help">
Markdown formatting supported.
</p>
</div>
<div class="field has-text-centered">
<button type="submit" class="button is-link"
name="intent" value="preview">
Preview
</button>
<button type="submit" class="button is-success"
name="intent" value="submit">
Post Comment
</button>
</div>
</form>
<hr class="is-dark">
{{if eq $Comments 0}}
<p>
<em>There are no comments yet.</em>
</p>
{{else}}
{{range .Comments}}
<div class="box has-background-link-light">
<div class="columns">
<div class="column is-2 has-text-centered">
<div>
<a href="/u/{{.User.Username}}">
<figure class="image is-96x96 is-inline-block">
{{if .User.ProfilePhoto.ID}}
<img src="{{PhotoURL .User.ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
</a>
</div>
<a href="/u/{{.User.Username}}">{{.User.Username}}</a>
</div>
<div class="column content">
{{ToMarkdown .Message}}
{{if .UpdatedAt.After .CreatedAt}}
<div class="mt-4">
<em title="{{.UpdatedAt.Format "2006-01-02 15:04:05"}}">
<small>Edited {{SincePrettyCoarse .UpdatedAt}} ago</small>
</em>
</div>
{{end}}
<hr class="has-background-grey mb-2">
<div class="columns is-mobile is-multiline is-size-7 mb-0">
<div class="column is-narrow">
<span title="{{.CreatedAt.Format "2006-01-02 15:04:05"}}">
{{SincePrettyCoarse .CreatedAt}} ago
</span>
</div>
<div class="column is-narrow">
<a href="/contact?intent=report&subject=report.comment&id={{.ID}}" class="has-text-dark">
<span class="icon"><i class="fa fa-flag"></i></span>
<span>Report</span>
</a>
</div>
{{if or $Root.CurrentUser.IsAdmin (eq $Root.CurrentUser.ID .User.ID)}}
<div class="column is-narrow">
<a href="/comments?table_name=photos&table_id={{$Root.Photo.ID}}&edit={{.ID}}&next={{UrlEncode $Root.Request.URL.Path "?id=" $Root.Photo.ID}}" class="has-text-dark">
<span class="icon"><i class="fa fa-edit"></i></span>
<span>Edit</span>
</a>
</div>
{{end}}
<!-- The poster, the photo owner, and the admin can delete the comment -->
{{if or $Root.CurrentUser.IsAdmin (eq $Root.CurrentUser.ID .User.ID) $Root.IsOwnPhoto}}
<div class="column is-narrow">
<a href="/comments?table_name=photos&table_id={{$Root.Photo.ID}}&edit={{.ID}}&delete=true&next={{UrlEncode $Root.Request.URL.Path "?id=" $Root.Photo.ID}}" onclick="return confirm('Are you sure you want to delete this comment?')" class="has-text-dark">
<span class="icon"><i class="fa fa-trash"></i></span>
<span>Delete</span>
</a>
</div>
{{end}}
</div>
</div>
</div>
</div>
{{end}}
{{end}}
</div>
</div>
</div>
</div>
{{end}}

View File

@ -25,7 +25,7 @@
</p>
<p>
This page was last updated on <strong>August 15, 2022.</strong>
This page was last updated on <strong>August 26, 2022.</strong>
</p>
<p>
@ -43,6 +43,21 @@
</p>
<ul>
<li>
You may mark your entire profile as "Private" which limits some of the contact you
may receive:
<ul>
<li>
Only users you have approved as a friend can see your profile and your
photo gallery.
</li>
<li>
Your photos will <strong>never</strong> appear on the Site Gallery - not
even to your friends. They will only see your photos by visiting your
profile page directly.
</li>
</ul>
</li>
<li>
Profile photos have visibility settings including Public, Friends-only or Private:
<ul>
@ -73,10 +88,12 @@
<p>
When you are uploading or editing a photo, there is a checkbox labeled "Gallery" where you
can opt your photo in (or out) of the Site Gallery. Only <strong>public</strong> photos will
ever appear on the Site Gallery (never private or friends-only photos). You are also able to
<em>exclude</em> a public photo from the Site Gallery by unchecking the "Gallery" box on that
photo.
can opt your photo in (or out) of the Site Gallery. Only your <strong>public</strong> photos
will appear on the Site Gallery by default; your <strong>friends-only</strong> photos may
appear there for people you approved as a friend, or your private photos to people for whom
you have granted access. You are also able to <em>exclude</em> a photo from the Site Gallery
by unchecking the "Gallery" box on that photo -- then it will only be viewable on your own
profile page, given its other permissions (friends/private).
</p>
<h3>Deletion of User Data</h3>