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:
parent
64d70efcf3
commit
748adeb289
|
@ -64,4 +64,37 @@ var (
|
|||
"interests",
|
||||
"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
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ var (
|
|||
PageSizeFriends = 12
|
||||
PageSizeBlockList = 12
|
||||
PageSizeAdminCertification = 20
|
||||
PageSizeAdminFeedback = 20
|
||||
PageSizeSiteGallery = 18
|
||||
PageSizeUserGallery = 18
|
||||
PageSizeInboxList = 20 // sidebar list
|
||||
|
|
162
pkg/controller/admin/feedback.go
Normal file
162
pkg/controller/admin/feedback.go
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
|
@ -28,11 +28,14 @@ func Inbox() http.HandlerFunc {
|
|||
var showSent = r.FormValue("box") == "sent"
|
||||
|
||||
// Are we reading a specific message?
|
||||
var viewThread []*models.Message
|
||||
var threadPager *models.Pagination
|
||||
var composeToUsername string
|
||||
var (
|
||||
viewThread []*models.Message
|
||||
threadPager *models.Pagination
|
||||
composeToUsername string
|
||||
msgId int
|
||||
)
|
||||
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 {
|
||||
session.FlashError(w, r, "Message not found.")
|
||||
templates.Redirect(w, "/messages")
|
||||
|
@ -127,6 +130,7 @@ func Inbox() http.HandlerFunc {
|
|||
"ViewThread": viewThread, // nil on inbox page
|
||||
"ThreadPager": threadPager,
|
||||
"ReplyTo": composeToUsername,
|
||||
"MessageID": msgId,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
165
pkg/controller/index/contact.go
Normal file
165
pkg/controller/index/contact.go
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
|
@ -77,13 +77,14 @@ func Send(msg Message) error {
|
|||
m.SetBody("text/plain", plaintext)
|
||||
m.AddAlternative("text/html", html.String())
|
||||
|
||||
// Deliver.
|
||||
d := gomail.NewDialer(conf.Host, conf.Port, conf.Username, conf.Password)
|
||||
|
||||
// Deliver asynchronously.
|
||||
log.Info("mail.Send: %s (%s) to %s", msg.Subject, msg.Template, msg.To)
|
||||
if err := d.DialAndSend(m); err != nil {
|
||||
log.Error("mail.Send: %s", err.Error())
|
||||
}
|
||||
d := gomail.NewDialer(conf.Host, conf.Port, conf.Username, conf.Password)
|
||||
go func() {
|
||||
if err := d.DialAndSend(m); err != nil {
|
||||
log.Error("mail.Send: %s", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
89
pkg/models/feedback.go
Normal file
89
pkg/models/feedback.go
Normal 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
|
||||
}
|
|
@ -15,4 +15,5 @@ func AutoMigrate() {
|
|||
DB.AutoMigrate(&Message{})
|
||||
DB.AutoMigrate(&Friend{})
|
||||
DB.AutoMigrate(&Block{})
|
||||
DB.AutoMigrate(&Feedback{})
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ func New() http.Handler {
|
|||
mux.HandleFunc("/faq", index.FAQ())
|
||||
mux.HandleFunc("/tos", index.TOS())
|
||||
mux.HandleFunc("/privacy", index.Privacy())
|
||||
mux.HandleFunc("/contact", index.Contact())
|
||||
mux.HandleFunc("/login", account.Login())
|
||||
mux.HandleFunc("/logout", account.Logout())
|
||||
mux.HandleFunc("/signup", account.Signup())
|
||||
|
@ -57,6 +58,7 @@ func New() http.Handler {
|
|||
// Admin endpoints.
|
||||
mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard()))
|
||||
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()))
|
||||
|
||||
// JSON API endpoints.
|
||||
|
|
|
@ -32,6 +32,7 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
|
|||
m["NavFriendRequests"] = 0
|
||||
m["NavAdminNotifications"] = 0 // total count of admin notifications for nav
|
||||
m["NavCertificationPhotos"] = 0 // admin indicator for certification photos
|
||||
m["NavAdminFeedback"] = 0 // admin indicator for unread feedback
|
||||
m["SessionImpersonated"] = false
|
||||
|
||||
if r == nil {
|
||||
|
@ -60,10 +61,16 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
|
|||
|
||||
// Are we admin?
|
||||
if user.IsAdmin {
|
||||
// Any pending certification photos?
|
||||
m["NavCertificationPhotos"] = models.CountCertificationPhotosNeedingApproval()
|
||||
// Any pending certification photos or feedback?
|
||||
var (
|
||||
certPhotos = models.CountCertificationPhotosNeedingApproval()
|
||||
feedback = models.CountUnreadFeedback()
|
||||
)
|
||||
m["NavCertificationPhotos"] = certPhotos
|
||||
m["NavAdminFeedback"] = feedback
|
||||
|
||||
// Total notification count for admin actions.
|
||||
m["NavAdminNotifications"] = certPhotos + feedback
|
||||
}
|
||||
}
|
||||
|
||||
m["NavAdminNotifications"] = m["NavCertificationPhotos"]
|
||||
}
|
||||
|
|
|
@ -120,7 +120,7 @@
|
|||
</a>
|
||||
</div>
|
||||
|
||||
<div class="column is-narrow has-text-centered">
|
||||
<!-- <div class="column is-narrow has-text-centered">
|
||||
<button type="button" class="button is-fullwidth">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
|
@ -129,18 +129,7 @@
|
|||
<span>Like</span>
|
||||
</span>
|
||||
</button>
|
||||
</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> -->
|
||||
|
||||
<div class="column is-narrow has-text-centered">
|
||||
<form action="/users/block" method="POST">
|
||||
|
@ -159,6 +148,17 @@
|
|||
</form>
|
||||
</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>
|
||||
</div>
|
||||
|
|
|
@ -29,6 +29,13 @@
|
|||
{{if .NavCertificationPhotos}}<span class="tag is-danger ml-1">{{.NavCertificationPhotos}}</span>{{end}}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/feedback">
|
||||
<span class="icon"><i class="fa fa-message"></i></span>
|
||||
Feedback & User Reports
|
||||
{{if .NavAdminFeedback}}<span class="tag is-danger ml-1">{{.NavAdminFeedback}}</span>{{end}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
190
web/templates/admin/feedback.html
Normal file
190
web/templates/admin/feedback.html
Normal file
|
@ -0,0 +1,190 @@
|
|||
{{define "title"}}Admin - Feedback & 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 & 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 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}}
|
|
@ -92,14 +92,11 @@
|
|||
<a class="navbar-item" href="/privacy">
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a class="navbar-item">
|
||||
Jobs
|
||||
</a>
|
||||
<a class="navbar-item" href="/contact">
|
||||
Contact
|
||||
</a>
|
||||
<hr class="navbar-divider">
|
||||
<a class="navbar-item" href="/feedback">
|
||||
<a class="navbar-item" href="/contact?intent=report">
|
||||
Report an issue
|
||||
</a>
|
||||
</div>
|
||||
|
|
119
web/templates/contact.html
Normal file
119
web/templates/contact.html
Normal 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}}
|
71
web/templates/email/contact_admin.html
Normal file
71
web/templates/email/contact_admin.html
Normal 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}}
|
|
@ -72,15 +72,19 @@
|
|||
<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">{{.NameOrUsername}}</p>
|
||||
<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>
|
||||
|
|
|
@ -33,7 +33,20 @@
|
|||
<textarea class="textarea block" cols="80" rows="4"
|
||||
name="message"
|
||||
placeholder="Write a response"></textarea>
|
||||
<button type="submit" class="button is-success">Send Reply</button>
|
||||
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<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>
|
||||
|
||||
<hr>
|
||||
|
|
|
@ -55,16 +55,14 @@
|
|||
|
||||
<!-- Reusable card footer -->
|
||||
{{define "card-footer"}}
|
||||
<footer class="card-footer">
|
||||
<a class="card-footer-item" href="/photo/edit?id={{.ID}}">
|
||||
<span class="icon"><i class="fa fa-edit"></i></span>
|
||||
Edit
|
||||
</a>
|
||||
<a class="card-footer-item has-text-danger" href="/photo/delete?id={{.ID}}">
|
||||
<span class="icon"><i class="fa fa-trash"></i></span>
|
||||
Delete
|
||||
</a>
|
||||
</footer>
|
||||
<a class="card-footer-item" href="/photo/edit?id={{.ID}}">
|
||||
<span class="icon"><i class="fa fa-edit"></i></span>
|
||||
<span>Edit</span>
|
||||
</a>
|
||||
<a class="card-footer-item has-text-danger" href="/photo/delete?id={{.ID}}">
|
||||
<span class="icon"><i class="fa fa-trash"></i></span>
|
||||
<span>Delete</span>
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
<!-- Reusable pager -->
|
||||
|
@ -246,9 +244,20 @@
|
|||
|
||||
{{template "card-body" .}}
|
||||
</div>
|
||||
{{if or $Root.IsOwnPhotos $Root.CurrentUser.IsAdmin}}
|
||||
{{template "card-footer" .}}
|
||||
{{end}}
|
||||
|
||||
<footer class="card-footer">
|
||||
{{if or $Root.IsOwnPhotos $Root.CurrentUser.IsAdmin}}
|
||||
{{template "card-footer" .}}
|
||||
{{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>
|
||||
{{end}}
|
||||
|
||||
|
@ -305,9 +314,19 @@
|
|||
|
||||
{{template "card-body" .}}
|
||||
</div>
|
||||
{{if or $Root.IsOwnPhotos $Root.CurrentUser.IsAdmin}}
|
||||
{{template "card-footer" .}}
|
||||
{{end}}
|
||||
|
||||
<footer class="card-footer">
|
||||
{{if or $Root.IsOwnPhotos $Root.CurrentUser.IsAdmin}}
|
||||
{{template "card-footer" .}}
|
||||
{{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}}
|
||||
|
|
Reference in New Issue
Block a user