Contact Us, Feedback, and Reporting

* Add the Contact page where users can contact the site admins for feedback or
  to report a problematic user, photo or message.
* Reports go into the admin Feedback table.
* Admin nav bar indicates number of unread feedbacks.
* Add "Report" button to profile pages, photo cards, and the top of Direct
  Message threads.

Misc changes:

* Send emails out asynchronously for more responsive page loads.
This commit is contained in:
Noah 2022-08-21 14:05:08 -07:00
parent 64d70efcf3
commit 748adeb289
19 changed files with 934 additions and 49 deletions

View File

@ -64,4 +64,37 @@ var (
"interests", "interests",
"music_movies", "music_movies",
} }
// Choices for the Contact Us subject
ContactUsChoices = []ContactUs{
{
Header: "Website Feedback",
Options: []Option{
{"feedback", "Website feedback"},
{"feature", "Make a feature request"},
{"bug", "Report a bug or broken feature"},
{"other", "General/miscellaneous/other"},
},
},
{
Header: "Report a Problem",
Options: []Option{
{"report.user", "Report a problematic user"},
{"report.photo", "Report a problematic photo"},
{"report.message", "Report a direct message conversation"},
},
},
}
) )
// ContactUs choices for the subject drop-down.
type ContactUs struct {
Header string
Options []Option
}
// Option for select boxes.
type Option struct {
Value string
Label string
}

View File

@ -6,6 +6,7 @@ var (
PageSizeFriends = 12 PageSizeFriends = 12
PageSizeBlockList = 12 PageSizeBlockList = 12
PageSizeAdminCertification = 20 PageSizeAdminCertification = 20
PageSizeAdminFeedback = 20
PageSizeSiteGallery = 18 PageSizeSiteGallery = 18
PageSizeUserGallery = 18 PageSizeUserGallery = 18
PageSizeInboxList = 20 // sidebar list PageSizeInboxList = 20 // sidebar list

View File

@ -0,0 +1,162 @@
package admin
import (
"fmt"
"net/http"
"strconv"
"git.kirsle.net/apps/gosocial/pkg/config"
"git.kirsle.net/apps/gosocial/pkg/models"
"git.kirsle.net/apps/gosocial/pkg/photo"
"git.kirsle.net/apps/gosocial/pkg/session"
"git.kirsle.net/apps/gosocial/pkg/templates"
)
// Feedback controller (/admin/feedback)
func Feedback() http.HandlerFunc {
tmpl := templates.Must("admin/feedback.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query params.
var (
acknowledged = r.FormValue("acknowledged") == "true"
intent = r.FormValue("intent")
visit = r.FormValue("visit") == "true" // visit the linked table ID
profile = r.FormValue("profile") == "true" // visit associated user profile
verdict = r.FormValue("verdict")
fb *models.Feedback
)
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get your current user: %s", err)
}
// Working on a target message?
if idStr := r.FormValue("id"); idStr != "" {
if idInt, err := strconv.Atoi(idStr); err != nil {
session.FlashError(w, r, "Couldn't parse id param: %s", err)
} else {
fb, err = models.GetFeedback(uint64(idInt))
if err != nil {
session.FlashError(w, r, "Couldn't load feedback message %d: %s", idInt, err)
}
}
}
// Are we visiting a linked resource (via TableID)?
if fb != nil && fb.TableID > 0 && visit {
switch fb.TableName {
case "users":
user, err := models.GetUser(fb.TableID)
if err != nil {
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
} else {
templates.Redirect(w, "/u/"+user.Username)
return
}
case "photos":
pic, err := models.GetPhoto(fb.TableID)
if err != nil {
session.FlashError(w, r, "Couldn't get photo %d: %s", fb.TableID, err)
} else {
// Going to the user's profile page?
if profile {
user, err := models.GetUser(pic.UserID)
if err != nil {
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
} else {
templates.Redirect(w, "/u/"+user.Username)
return
}
}
// Direct link to the photo.
templates.Redirect(w, photo.URLPath(pic.Filename))
return
}
case "messages":
// To read this message we will need to impersonate the reporter.
user, err := models.GetUser(fb.UserID)
if err != nil {
session.FlashError(w, r, "Couldn't get reporting user ID %d: %s", fb.UserID, err)
} else {
if err := session.ImpersonateUser(w, r, user, currentUser); err != nil {
session.FlashError(w, r, "Couldn't impersonate user: %s", err)
} else {
// Redirect to the thread.
session.Flash(w, r, "NOTICE: You are now impersonating %s to view their inbox.", user.Username)
templates.Redirect(w, fmt.Sprintf("/messages/read/%d", fb.TableID))
return
}
}
default:
session.FlashError(w, r, "Couldn't visit TableID %s/%d: not a supported TableName", fb.TableName, fb.TableID)
}
}
// Are we (un)acknowledging a message?
if r.Method == http.MethodPost {
if fb == nil {
session.FlashError(w, r, "Missing feedback ID for this POST!")
} else {
switch verdict {
case "acknowledge":
fb.Acknowledged = true
if err := fb.Save(); err != nil {
session.FlashError(w, r, "Couldn't save message: %s", err)
} else {
session.Flash(w, r, "Message acknowledged!")
}
case "unacknowledge":
fb.Acknowledged = false
if err := fb.Save(); err != nil {
session.FlashError(w, r, "Couldn't save message: %s", err)
} else {
session.Flash(w, r, "Message acknowledged!")
}
default:
session.FlashError(w, r, "Unsupported verdict: %s", verdict)
}
}
templates.Redirect(w, r.URL.Path)
return
}
// Get the feedback.
pager := &models.Pagination{
Page: 1,
PerPage: config.PageSizeAdminFeedback,
Sort: "updated_at desc",
}
pager.ParsePage(r)
page, err := models.PaginateFeedback(acknowledged, intent, pager)
if err != nil {
session.FlashError(w, r, "Couldn't load feedback from DB: %s", err)
}
// Map user IDs.
var userIDs = []uint64{}
for _, p := range page {
if p.UserID > 0 {
userIDs = append(userIDs, p.UserID)
}
}
userMap, err := models.MapUsers(userIDs)
if err != nil {
session.FlashError(w, r, "Couldn't map user IDs: %s", err)
}
var vars = map[string]interface{}{
"Intent": intent,
"Acknowledged": acknowledged,
"Feedback": page,
"UserMap": userMap,
"Pager": pager,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -28,11 +28,14 @@ func Inbox() http.HandlerFunc {
var showSent = r.FormValue("box") == "sent" var showSent = r.FormValue("box") == "sent"
// Are we reading a specific message? // Are we reading a specific message?
var viewThread []*models.Message var (
var threadPager *models.Pagination viewThread []*models.Message
var composeToUsername string threadPager *models.Pagination
composeToUsername string
msgId int
)
if uri := ReadURLRegexp.FindStringSubmatch(r.URL.Path); uri != nil { if uri := ReadURLRegexp.FindStringSubmatch(r.URL.Path); uri != nil {
msgId, _ := strconv.Atoi(uri[1]) msgId, _ = strconv.Atoi(uri[1])
if msg, err := models.GetMessage(uint64(msgId)); err != nil { if msg, err := models.GetMessage(uint64(msgId)); err != nil {
session.FlashError(w, r, "Message not found.") session.FlashError(w, r, "Message not found.")
templates.Redirect(w, "/messages") templates.Redirect(w, "/messages")
@ -127,6 +130,7 @@ func Inbox() http.HandlerFunc {
"ViewThread": viewThread, // nil on inbox page "ViewThread": viewThread, // nil on inbox page
"ThreadPager": threadPager, "ThreadPager": threadPager,
"ReplyTo": composeToUsername, "ReplyTo": composeToUsername,
"MessageID": msgId,
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -0,0 +1,165 @@
package index
import (
"fmt"
"html/template"
"net/http"
"strconv"
"git.kirsle.net/apps/gosocial/pkg/config"
"git.kirsle.net/apps/gosocial/pkg/log"
"git.kirsle.net/apps/gosocial/pkg/mail"
"git.kirsle.net/apps/gosocial/pkg/markdown"
"git.kirsle.net/apps/gosocial/pkg/models"
"git.kirsle.net/apps/gosocial/pkg/session"
"git.kirsle.net/apps/gosocial/pkg/templates"
)
// Contact or report a problem.
func Contact() http.HandlerFunc {
tmpl := templates.Must("contact.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query and form POST parameters.
var (
intent = r.FormValue("intent")
subject = r.FormValue("subject")
title = "Contact Us"
message = r.FormValue("message")
replyTo = r.FormValue("email")
tableID int
tableName string
tableLabel string // front-end user feedback about selected report item
messageRequired = true // unless we have a table ID to work with
success = "Thank you for your feedback! Your message has been delivered to the website administrators."
)
// For report intents: ID of the user, photo, message, etc.
tableID, _ = strconv.Atoi(r.FormValue("id"))
if tableID > 0 {
messageRequired = false
}
// In what context is the ID given?
if subject != "" && tableID > 0 {
switch subject {
case "report.user":
tableName = "users"
if user, err := models.GetUser(uint64(tableID)); err == nil {
tableLabel = fmt.Sprintf(`User account "%s"`, user.Username)
} else {
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
}
case "report.photo":
tableName = "photos"
// Find this photo and the user associated.
if pic, err := models.GetPhoto(uint64(tableID)); err == nil {
if user, err := models.GetUser(pic.UserID); err == nil {
tableLabel = fmt.Sprintf(`A profile photo of user account "%s"`, user.Username)
} else {
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
}
} else {
log.Error("/contact: couldn't produce table label for photo %d: %s", tableID, err)
}
case "report.message":
tableName = "messages"
tableLabel = "Direct Message conversation"
}
}
// On POST: take what we have now and email the admins.
if r.Method == http.MethodPost {
// Look up the current user, in case logged in.
currentUser, err := session.CurrentUser(r)
if err == nil {
replyTo = currentUser.Email
}
// Store feedback in the database.
fb := &models.Feedback{
Intent: intent,
Subject: subject,
Message: message,
TableName: tableName,
TableID: uint64(tableID),
}
if currentUser != nil && currentUser.ID > 0 {
fb.UserID = currentUser.ID
} else if replyTo != "" {
fb.ReplyTo = replyTo
}
if err := models.CreateFeedback(fb); err != nil {
session.FlashError(w, r, "Couldn't save feedback: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
// Email the admins.
if err := mail.Send(mail.Message{
To: config.Current.AdminEmail,
Subject: "User Feedback: " + title,
Template: "email/contact_admin.html",
Data: map[string]interface{}{
"Title": title,
"Intent": intent,
"Subject": subject,
"Message": template.HTML(markdown.Render(message)),
"TableName": tableName,
"TableID": tableID,
"CurrentUser": currentUser,
"ReplyTo": replyTo,
"BaseURL": config.Current.BaseURL,
"AdminURL": config.Current.BaseURL + "/admin/feedback",
},
}); err != nil {
log.Error("/contact page: couldn't send email: %s", err)
}
session.Flash(w, r, success)
templates.Redirect(w, r.URL.Path)
return
}
// Default intent = contact
if intent == "report" {
title = "Report a Problem"
} else {
intent = "contact"
}
// Validate the subject.
if subject != "" {
var found bool
for _, group := range config.ContactUsChoices {
for _, opt := range group.Options {
if opt.Value == subject {
found = true
break
}
}
}
if !found {
subject = ""
}
}
var vars = map[string]interface{}{
"Intent": intent,
"TableID": tableID,
"TableLabel": tableLabel,
"Subject": subject,
"PageTitle": title,
"Subjects": config.ContactUsChoices,
"Message": message,
"MessageRequired": messageRequired,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -77,13 +77,14 @@ func Send(msg Message) error {
m.SetBody("text/plain", plaintext) m.SetBody("text/plain", plaintext)
m.AddAlternative("text/html", html.String()) m.AddAlternative("text/html", html.String())
// Deliver. // Deliver asynchronously.
d := gomail.NewDialer(conf.Host, conf.Port, conf.Username, conf.Password)
log.Info("mail.Send: %s (%s) to %s", msg.Subject, msg.Template, msg.To) log.Info("mail.Send: %s (%s) to %s", msg.Subject, msg.Template, msg.To)
d := gomail.NewDialer(conf.Host, conf.Port, conf.Username, conf.Password)
go func() {
if err := d.DialAndSend(m); err != nil { if err := d.DialAndSend(m); err != nil {
log.Error("mail.Send: %s", err.Error()) log.Error("mail.Send: %s", err.Error())
} }
}()
return nil return nil
} }

89
pkg/models/feedback.go Normal file
View File

@ -0,0 +1,89 @@
package models
import (
"strings"
"time"
"git.kirsle.net/apps/gosocial/pkg/log"
)
// Feedback table for Contact Us & Reporting to admins.
type Feedback struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"index"` // if logged-in user posted this
Acknowledged bool `gorm:"index"` // admin dashboard "read" status
Intent string
Subject string
Message string
TableName string
TableID uint64
ReplyTo string // logged-out user may leave their email for reply
CreatedAt time.Time
UpdatedAt time.Time
}
// GetFeedback by ID.
func GetFeedback(id uint64) (*Feedback, error) {
m := &Feedback{}
result := DB.First(&m, id)
return m, result.Error
}
// CountUnreadFeedback gets the count of unacknowledged feedback for admins.
func CountUnreadFeedback() int64 {
query := DB.Where(
"acknowledged = ?",
false,
)
var count int64
result := query.Model(&Feedback{}).Count(&count)
if result.Error != nil {
log.Error("models.CountUnreadFeedback: %s", result.Error)
}
return count
}
// PaginateFeedback
func PaginateFeedback(acknowledged bool, intent string, pager *Pagination) ([]*Feedback, error) {
var (
fb = []*Feedback{}
wheres = []string{}
placeholders = []interface{}{}
)
wheres = append(wheres, "acknowledged = ?")
placeholders = append(placeholders, acknowledged)
if intent != "" {
wheres = append(wheres, "intent = ?")
placeholders = append(placeholders, intent)
}
query := DB.Where(
strings.Join(wheres, " AND "),
placeholders...,
).Order(
pager.Sort,
)
query.Model(&Feedback{}).Count(&pager.Total)
result := query.Offset(
pager.GetOffset(),
).Limit(pager.PerPage).Find(&fb)
return fb, result.Error
}
// CreateFeedback saves a new Feedback row to the DB.
func CreateFeedback(fb *Feedback) error {
result := DB.Create(fb)
return result.Error
}
// Save Feedback.
func (fb *Feedback) Save() error {
result := DB.Save(fb)
return result.Error
}

View File

@ -15,4 +15,5 @@ func AutoMigrate() {
DB.AutoMigrate(&Message{}) DB.AutoMigrate(&Message{})
DB.AutoMigrate(&Friend{}) DB.AutoMigrate(&Friend{})
DB.AutoMigrate(&Block{}) DB.AutoMigrate(&Block{})
DB.AutoMigrate(&Feedback{})
} }

View File

@ -25,6 +25,7 @@ func New() http.Handler {
mux.HandleFunc("/faq", index.FAQ()) mux.HandleFunc("/faq", index.FAQ())
mux.HandleFunc("/tos", index.TOS()) mux.HandleFunc("/tos", index.TOS())
mux.HandleFunc("/privacy", index.Privacy()) mux.HandleFunc("/privacy", index.Privacy())
mux.HandleFunc("/contact", index.Contact())
mux.HandleFunc("/login", account.Login()) mux.HandleFunc("/login", account.Login())
mux.HandleFunc("/logout", account.Logout()) mux.HandleFunc("/logout", account.Logout())
mux.HandleFunc("/signup", account.Signup()) mux.HandleFunc("/signup", account.Signup())
@ -57,6 +58,7 @@ func New() http.Handler {
// Admin endpoints. // Admin endpoints.
mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard())) mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard()))
mux.Handle("/admin/photo/certification", middleware.AdminRequired(photo.AdminCertification())) mux.Handle("/admin/photo/certification", middleware.AdminRequired(photo.AdminCertification()))
mux.Handle("/admin/feedback", middleware.AdminRequired(admin.Feedback()))
mux.Handle("/admin/user-action", middleware.AdminRequired(admin.UserActions())) mux.Handle("/admin/user-action", middleware.AdminRequired(admin.UserActions()))
// JSON API endpoints. // JSON API endpoints.

View File

@ -32,6 +32,7 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
m["NavFriendRequests"] = 0 m["NavFriendRequests"] = 0
m["NavAdminNotifications"] = 0 // total count of admin notifications for nav m["NavAdminNotifications"] = 0 // total count of admin notifications for nav
m["NavCertificationPhotos"] = 0 // admin indicator for certification photos m["NavCertificationPhotos"] = 0 // admin indicator for certification photos
m["NavAdminFeedback"] = 0 // admin indicator for unread feedback
m["SessionImpersonated"] = false m["SessionImpersonated"] = false
if r == nil { if r == nil {
@ -60,10 +61,16 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
// Are we admin? // Are we admin?
if user.IsAdmin { if user.IsAdmin {
// Any pending certification photos? // Any pending certification photos or feedback?
m["NavCertificationPhotos"] = models.CountCertificationPhotosNeedingApproval() var (
} certPhotos = models.CountCertificationPhotosNeedingApproval()
} feedback = models.CountUnreadFeedback()
)
m["NavCertificationPhotos"] = certPhotos
m["NavAdminFeedback"] = feedback
m["NavAdminNotifications"] = m["NavCertificationPhotos"] // Total notification count for admin actions.
m["NavAdminNotifications"] = certPhotos + feedback
}
}
} }

View File

@ -120,7 +120,7 @@
</a> </a>
</div> </div>
<div class="column is-narrow has-text-centered"> <!-- <div class="column is-narrow has-text-centered">
<button type="button" class="button is-fullwidth"> <button type="button" class="button is-fullwidth">
<span class="icon-text"> <span class="icon-text">
<span class="icon"> <span class="icon">
@ -129,18 +129,7 @@
<span>Like</span> <span>Like</span>
</span> </span>
</button> </button>
</div> </div> -->
<!-- <div class="column is-narrow has-text-centered">
<button type="button" class="button is-fullwidth">
<span class="icon-text">
<span class="icon">
<i class="fa fa-flag"></i>
</span>
<span>Flag</span>
</span>
</button>
</div>-->
<div class="column is-narrow has-text-centered"> <div class="column is-narrow has-text-centered">
<form action="/users/block" method="POST"> <form action="/users/block" method="POST">
@ -159,6 +148,17 @@
</form> </form>
</div> </div>
<div class="column is-narrow has-text-centered">
<a href="/contact?intent=report&subject=report.user&id={{.User.ID}}" class="button is-fullwidth">
<span class="icon-text">
<span class="icon">
<i class="fa fa-flag"></i>
</span>
<span>Report</span>
</span>
</a>
</div>
</div><!-- columns --> </div><!-- columns -->
</div> </div>
</div> </div>

View File

@ -29,6 +29,13 @@
{{if .NavCertificationPhotos}}<span class="tag is-danger ml-1">{{.NavCertificationPhotos}}</span>{{end}} {{if .NavCertificationPhotos}}<span class="tag is-danger ml-1">{{.NavCertificationPhotos}}</span>{{end}}
</a> </a>
</li> </li>
<li>
<a href="/admin/feedback">
<span class="icon"><i class="fa fa-message"></i></span>
Feedback &amp; User Reports
{{if .NavAdminFeedback}}<span class="tag is-danger ml-1">{{.NavAdminFeedback}}</span>{{end}}
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -0,0 +1,190 @@
{{define "title"}}Admin - Feedback &amp; User Reports{{end}}
{{define "content"}}
{{$Root := .}}
<div class="container">
<section class="hero is-danger is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
Feedback &amp; User Reports
</h1>
</div>
</div>
</section>
<div class="block p-4">
<div class="columns">
<div class="column">
There {{Pluralize64 .Pager.Total "is" "are"}} <strong>{{.Pager.Total}}</strong>
{{if .Acknowledged}}acknowledged{{else}}unread{{end}}
{{if eq .Intent "report"}}report{{else}}feedback message{{end}}{{Pluralize64 .Pager.Total}}.
</div>
<div class="column is-narrow">
<div class="tabs is-toggle">
<ul>
<li{{if eq .Intent ""}} class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?acknowledged={{.Acknowledged}}">All</a>
</li>
<li{{if eq .Intent "contact"}} class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?acknowledged={{.Acknowledged}}&intent=contact">Contact</a>
</li>
<li{{if eq .Intent "report"}} class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?acknowledged={{.Acknowledged}}&intent=report">Reports</a>
</li>
</ul>
</div>
</div>
<div class="column is-narrow">
<div class="tabs is-toggle">
<ul>
<li{{if not .Acknowledged}} class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?intent={{.Intent}}">Unread</a>
</li>
<li{{if .Acknowledged}} class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?acknowledged=true&intent={{.Intent}}">Acknowledged</a>
</li>
</ul>
</div>
</div>
</div>
{{if .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={{.View}}&page={{.Pager.Previous}}">Previous</a>
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
href="{{.Request.URL.Path}}?view={{.View}}&page={{.Pager.Next}}">Next page</a>
<ul class="pagination-list">
{{range .Pager.Iter}}
<li>
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
aria-label="Page {{.Page}}"
href="{{$Root.Request.URL.Path}}?view={{$Root.View}}&page={{.Page}}">
{{.Page}}
</a>
</li>
{{end}}
</ul>
</nav>
{{end}}
<div class="columns is-multiline">
{{range .Feedback}}
<div class="column is-one-third">
{{$User := $Root.UserMap.Get .UserID}}
<form action="{{$Root.Request.URL.Path}}" method="POST">
{{InputCSRF}}
<input type="hidden" name="id" value="{{.ID}}">
<div class="card" style="max-width: 512px">
<header class="card-header {{if eq .Intent "report"}}has-background-danger{{else}}has-background-link{{end}}">
<p class="card-header-title has-text-light">
{{if eq .Intent "report"}}
<span class="icon"><i class="fa fa-flag"></i></span>
<span>Report: {{.Subject}}</span>
{{else}}
<span class="icon"><i class="fa fa-message"></i></span>
<span>Contact: {{.Subject}}</span>
{{end}}
</p>
</header>
<div class="card-content">
<table class="table is-fullwidth">
<tr>
<td class="has-text-right is-narrow">
<strong>Intent:</strong>
</td>
<td>{{.Intent}}</td>
</tr>
<tr>
<td class="has-text-right">
<strong>Subject:</strong>
</td>
<td>{{.Subject}}</td>
</tr>
<tr>
<td class="has-text-right">
<strong>Table:</strong>
</td>
<td>
{{if eq .TableName ""}}
n/a
{{if ne .TableID 0}} - {{.TableID}}{{end}}
{{else if eq .TableName "users"}}
Users: {{.TableID}}
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true"
class="fa fa-external-link ml-2"
target="_blank"
title="Visit the reported user's profile"></a>
{{else if eq .TableName "photos"}}
Photos: {{.TableID}}
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true"
class="fa fa-external-link mx-2"
target="_blank"
title="Visit the reported photo"></a>
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true&profile=true"
class="fa fa-user"
target="_blank"
title="Visit the user profile who owns the reported photo"></a>
{{else if eq .TableName "messages"}}
Messages: {{.TableID}}
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true"
class="fa fa-ghost ml-2"
target="_blank"
title="Impersonate the reporter and view this message thread"></a>
{{else}}
{{.TableName}}: {{.TableID}}
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true" class="fa fa-external-link ml-2" target="_blank"></a>
{{end}}
</td>
</tr>
<tr>
<td class="has-text-right">
<strong>Reply&nbsp;To:</strong>
</td>
<td>
{{if $User}}
<a href="/u/{{$User.Username}}">{{$User.Username}}</a>
{{else if ne .ReplyTo ""}}
<a href="mailto:{{.ReplyTo}}">{{.ReplyTo}}</a>
{{else}}
n/a
{{end}}
</td>
</tr>
</table>
<div class="content">
{{if eq .Message ""}}
<p><em>No message attached.</em></p>
{{else}}
{{ToMarkdown .Message}}
{{end}}
</div>
</div>
<footer class="card-footer">
{{if not .Acknowledged}}
<button type="submit" name="verdict" value="acknowledge" class="card-footer-item button is-success">
<span class="icon"><i class="fa fa-check"></i></span>
<span>Acknowledge</span>
</button>
{{end}}
{{if .Acknowledged}}
<button type="submit" name="verdict" value="unacknowledge" class="card-footer-item button is-warning">
<span class="icon"><i class="fa fa-xmark"></i></span>
<span>Mark Unread</span>
</button>
{{end}}
</footer>
</div>
</form>
</div>
{{end}}
</div>
</div>
</div>
{{end}}

View File

@ -92,14 +92,11 @@
<a class="navbar-item" href="/privacy"> <a class="navbar-item" href="/privacy">
Privacy Policy Privacy Policy
</a> </a>
<a class="navbar-item">
Jobs
</a>
<a class="navbar-item" href="/contact"> <a class="navbar-item" href="/contact">
Contact Contact
</a> </a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<a class="navbar-item" href="/feedback"> <a class="navbar-item" href="/contact?intent=report">
Report an issue Report an issue
</a> </a>
</div> </div>

119
web/templates/contact.html Normal file
View File

@ -0,0 +1,119 @@
{{define "title"}}{{.PageTitle}}{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-info is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
{{.PageTitle}}
</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>
<span>{{.PageTitle}}</span>
</p>
</header>
<div class="card-content">
<form action="/contact" method="POST">
{{InputCSRF}}
<input type="hidden" name="intent" value="{{.Intent}}">
<input type="hidden" name="id" value="{{.TableID}}">
<p class="block">
You may use this form to contact the site administrators to provide
feedback, criticism, or to report a problem you have found on the
site such as inappropriate content posted by one of our members.
</p>
<div class="field block">
<label for="subject" class="label">Subject</label>
<div class="select is-fullwidth">
{{$Subject := .Subject}}
<select name="subject" id="subject"{{if ne $Subject ""}} disabled{{end}}>
{{range .Subjects}}
<optgroup label="{{.Header}}">
{{range .Options}}
<option value="{{.Value}}"{{if eq $Subject .Value}} selected{{end}}>{{.Label}}</option>
{{end}}
</optgroup>
{{end}}
</select>
</div>
<!-- In case the selectbox is disabled -->
{{if ne .Subject ""}}<input type="hidden" name="subject" value="{{.Subject}}">{{end}}
</div>
<!-- If they are reporting on a TableID and we have a label, show it -->
{{if ne .TableLabel ""}}
<div class="field block">
<label class="label">About</label>
<p>{{.TableLabel}}</p>
</div>
{{end}}
<div class="field block">
<label for="message" class="label">Message{{if not .MessageRequired}} (optional){{else}} (required){{end}}</label>
<textarea class="textarea" cols="80" rows="4"
name="message"
id="message"
placeholder="Message"
{{if .MessageRequired}}required{{end}}>{{.Message}}</textarea>
{{if not .MessageRequired}}
<p class="help">
Write a description of the problem (optional).
</p>
{{end}}
</div>
{{if not .LoggedIn}}
<div class="field block">
<label for="email" class="label">Reply To (optional)</label>
<input type="email" class="input"
name="email" id="email"
placeholder="name@domain.com">
<p class="help">
Optional; you are not logged in to an account so you <em>MAY</em> leave us
a reply-to email address if you would like a response to your feedback.
</p>
</div>
{{end}}
<div class="field has-text-centered">
<button type="submit" class="button is-success">
Send Message
</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

@ -0,0 +1,71 @@
{{define "content"}}
<html>
<body bakground="#ffffff" color="#000000" link="#0000FF" vlink="#990099" alink="#FF0000">
<basefont face="Arial,Helvetica,sans-serif" size="3" color="#000000"></basefont>
<h1>User Feedback: {{.Data.Title}}</h1>
<p>
Dear website administrators,
</p>
<p>
A user has posted a message to you via the Contact Us form. This message
is viewable on your admin dashboard and the details are also copied below:
</p>
<ul>
<li>
<strong>Intent:</strong> {{.Data.Intent}}
</li>
<li>
<strong>Subject:</strong> {{.Data.Subject}}
</li>
<li>
<strong>Table ID:</strong>
{{.Data.TableID}} ({{or .Data.TableName "n/a"}})
</li>
<li>
<strong>Current User:</strong>
{{if .Data.CurrentUser}}
<a href="{{.Data.BaseURL}}/u/{{.Data.CurrentUser.Username}}">{{.Data.CurrentUser.Username}}</a>
(ID {{.Data.CurrentUser.ID}})
{{else}}
<em>not a logged-in user</em>
{{end}}
</li>
<li>
<strong>Reply To:</strong>
{{if .Data.ReplyTo}}
<a href="mailto:{{.Data.ReplyTo}}">{{.Data.ReplyTo}}</a>
{{else}}
<em>User did not leave a reply-to address.</em>
{{end}}
</li>
</ul>
<p>
The user's message was as follows:
</p>
<hr>
{{if ne .Data.Message ""}}
{{.Data.Message}}
{{else}}
<em>No message attached.</em>
{{end}}
<hr>
<p>
To view this message on the admin dashboard, please visit:
<a href="{{.Data.AdminURL}}">{{.Data.AdminURL}}</a>
</p>
<p>
This is an automated e-mail; do not reply to this message.
</p>
</body>
</html>
{{end}}

View File

@ -72,15 +72,19 @@
<div class="media block"> <div class="media block">
<div class="media-left"> <div class="media-left">
<figure class="image is-64x64"> <figure class="image is-64x64">
<a href="/u/{{.Username}}">
{{if .ProfilePhoto.ID}} {{if .ProfilePhoto.ID}}
<img src="{{PhotoURL .ProfilePhoto.CroppedFilename}}"> <img src="{{PhotoURL .ProfilePhoto.CroppedFilename}}">
{{else}} {{else}}
<img src="/static/img/shy.png"> <img src="/static/img/shy.png">
{{end}} {{end}}
</a>
</figure> </figure>
</div> </div>
<div class="media-content"> <div class="media-content">
<p class="title is-4">{{.NameOrUsername}}</p> <p class="title is-4">
<a href="/u/{{.Username}}" class="has-text-dark">{{.NameOrUsername}}</a>
</p>
<p class="subtitle is-6"> <p class="subtitle is-6">
<span class="icon"><i class="fa fa-user"></i></span> <span class="icon"><i class="fa fa-user"></i></span>
<a href="/u/{{.Username}}">{{.Username}}</a> <a href="/u/{{.Username}}">{{.Username}}</a>

View File

@ -33,7 +33,20 @@
<textarea class="textarea block" cols="80" rows="4" <textarea class="textarea block" cols="80" rows="4"
name="message" name="message"
placeholder="Write a response"></textarea> placeholder="Write a response"></textarea>
<div class="columns is-mobile">
<div class="column">
<button type="submit" class="button is-success">Send Reply</button> <button type="submit" class="button is-success">Send Reply</button>
</div>
<div class="column is-narrow">
<a href="/contact?intent=report&subject=report.message&id={{.MessageID}}"
class="button has-text-danger ml-4">
<span class="icon"><i class="fa fa-flag"></i></span>
<span>Report</span>
</a>
</div>
</div>
</form> </form>
<hr> <hr>

View File

@ -55,16 +55,14 @@
<!-- Reusable card footer --> <!-- Reusable card footer -->
{{define "card-footer"}} {{define "card-footer"}}
<footer class="card-footer">
<a class="card-footer-item" href="/photo/edit?id={{.ID}}"> <a class="card-footer-item" href="/photo/edit?id={{.ID}}">
<span class="icon"><i class="fa fa-edit"></i></span> <span class="icon"><i class="fa fa-edit"></i></span>
Edit <span>Edit</span>
</a> </a>
<a class="card-footer-item has-text-danger" href="/photo/delete?id={{.ID}}"> <a class="card-footer-item has-text-danger" href="/photo/delete?id={{.ID}}">
<span class="icon"><i class="fa fa-trash"></i></span> <span class="icon"><i class="fa fa-trash"></i></span>
Delete <span>Delete</span>
</a> </a>
</footer>
{{end}} {{end}}
<!-- Reusable pager --> <!-- Reusable pager -->
@ -246,9 +244,20 @@
{{template "card-body" .}} {{template "card-body" .}}
</div> </div>
<footer class="card-footer">
{{if or $Root.IsOwnPhotos $Root.CurrentUser.IsAdmin}} {{if or $Root.IsOwnPhotos $Root.CurrentUser.IsAdmin}}
{{template "card-footer" .}} {{template "card-footer" .}}
{{end}} {{end}}
{{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>
</a>
{{end}}
</footer>
</div> </div>
{{end}} {{end}}
@ -305,9 +314,19 @@
{{template "card-body" .}} {{template "card-body" .}}
</div> </div>
<footer class="card-footer">
{{if or $Root.IsOwnPhotos $Root.CurrentUser.IsAdmin}} {{if or $Root.IsOwnPhotos $Root.CurrentUser.IsAdmin}}
{{template "card-footer" .}} {{template "card-footer" .}}
{{end}} {{end}}
{{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>
</a>
{{end}}
</footer>
</div> </div>
</div> </div>
{{end}} {{end}}