Merge pull request 'Forums, Likes & Notifications' (#3) from forums into master
Reviewed-on: https://git.kirsle.net/apps/gosocial/pulls/3
This commit is contained in:
commit
754cbda7bc
|
@ -82,9 +82,18 @@ var (
|
||||||
{"report.user", "Report a problematic user"},
|
{"report.user", "Report a problematic user"},
|
||||||
{"report.photo", "Report a problematic photo"},
|
{"report.photo", "Report a problematic photo"},
|
||||||
{"report.message", "Report a direct message conversation"},
|
{"report.message", "Report a direct message conversation"},
|
||||||
|
{"report.comment", "Report a forum post or comment"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default forum categories for forum landing page.
|
||||||
|
ForumCategories = []string{
|
||||||
|
"Rules and Announcements",
|
||||||
|
"Nudists",
|
||||||
|
"Exhibitionists",
|
||||||
|
"Anything Goes",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContactUs choices for the subject drop-down.
|
// ContactUs choices for the subject drop-down.
|
||||||
|
|
|
@ -2,13 +2,17 @@ package config
|
||||||
|
|
||||||
// Pagination sizes per page.
|
// Pagination sizes per page.
|
||||||
var (
|
var (
|
||||||
PageSizeMemberSearch = 60
|
PageSizeMemberSearch = 60
|
||||||
PageSizeFriends = 12
|
PageSizeFriends = 12
|
||||||
PageSizeBlockList = 12
|
PageSizeBlockList = 12
|
||||||
PageSizeAdminCertification = 20
|
PageSizeAdminCertification = 20
|
||||||
PageSizeAdminFeedback = 20
|
PageSizeAdminFeedback = 20
|
||||||
PageSizeSiteGallery = 18
|
PageSizeSiteGallery = 18
|
||||||
PageSizeUserGallery = 18
|
PageSizeUserGallery = 18
|
||||||
PageSizeInboxList = 20 // sidebar list
|
PageSizeInboxList = 20 // sidebar list
|
||||||
PageSizeInboxThread = 20 // conversation view
|
PageSizeInboxThread = 20 // conversation view
|
||||||
|
PageSizeForums = 100 // TODO: for main category index view
|
||||||
|
PageSizeThreadList = 20 // 20 threads per board, 20 posts per thread
|
||||||
|
PageSizeForumAdmin = 20
|
||||||
|
PageSizeDashboardNotifications = 50
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,6 +3,9 @@ package account
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||||
"git.kirsle.net/apps/gosocial/pkg/templates"
|
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -10,7 +13,41 @@ import (
|
||||||
func Dashboard() http.HandlerFunc {
|
func Dashboard() http.HandlerFunc {
|
||||||
tmpl := templates.Must("account/dashboard.html")
|
tmpl := templates.Must("account/dashboard.html")
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := tmpl.Execute(w, r, nil); err != nil {
|
currentUser, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Couldn't get currentUser", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark all notifications read?
|
||||||
|
if r.FormValue("intent") == "read-notifications" {
|
||||||
|
models.MarkNotificationsRead(currentUser)
|
||||||
|
session.Flash(w, r, "All of your notifications have been marked as 'read!'")
|
||||||
|
templates.Redirect(w, "/me")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get our notifications.
|
||||||
|
pager := &models.Pagination{
|
||||||
|
Page: 1,
|
||||||
|
PerPage: config.PageSizeDashboardNotifications,
|
||||||
|
Sort: "created_at desc",
|
||||||
|
}
|
||||||
|
pager.ParsePage(r)
|
||||||
|
notifs, err := models.PaginateNotifications(currentUser, pager)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't get your notifications: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map our notifications.
|
||||||
|
notifMap := models.MapNotifications(notifs)
|
||||||
|
|
||||||
|
var vars = map[string]interface{}{
|
||||||
|
"Notifications": notifs,
|
||||||
|
"NotifMap": notifMap,
|
||||||
|
"Pager": pager,
|
||||||
|
}
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,8 +57,12 @@ func Profile() http.HandlerFunc {
|
||||||
isPrivate = !currentUser.IsAdmin && !isSelf && user.Visibility == models.UserVisibilityPrivate && isFriend != "approved"
|
isPrivate = !currentUser.IsAdmin && !isSelf && user.Visibility == models.UserVisibilityPrivate && isFriend != "approved"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Get Likes for this profile.
|
||||||
|
likeMap := models.MapLikes(currentUser, "users", []uint64{user.ID})
|
||||||
|
|
||||||
vars := map[string]interface{}{
|
vars := map[string]interface{}{
|
||||||
"User": user,
|
"User": user,
|
||||||
|
"LikeMap": likeMap,
|
||||||
"IsFriend": isFriend,
|
"IsFriend": isFriend,
|
||||||
"IsPrivate": isPrivate,
|
"IsPrivate": isPrivate,
|
||||||
}
|
}
|
||||||
|
|
|
@ -89,6 +89,20 @@ func Feedback() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "comments":
|
||||||
|
// Get this comment.
|
||||||
|
comment, err := models.GetComment(fb.TableID)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't get comment ID %d: %s", fb.TableID, err)
|
||||||
|
} else {
|
||||||
|
// What was the comment on?
|
||||||
|
switch comment.TableName {
|
||||||
|
case "threads":
|
||||||
|
// Visit the thread.
|
||||||
|
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", comment.TableID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
session.FlashError(w, r, "Couldn't visit TableID %s/%d: not a supported TableName", fb.TableName, fb.TableID)
|
session.FlashError(w, r, "Couldn't visit TableID %s/%d: not a supported TableName", fb.TableName, fb.TableID)
|
||||||
}
|
}
|
||||||
|
|
55
pkg/controller/api/json_layer.go
Normal file
55
pkg/controller/api/json_layer.go
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Envelope is the standard JSON response envelope.
|
||||||
|
type Envelope struct {
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseJSON request body.
|
||||||
|
func ParseJSON(r *http.Request, v interface{}) error {
|
||||||
|
if r.Header.Get("Content-Type") != "application/json" {
|
||||||
|
return errors.New("request Content-Type must be application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body.
|
||||||
|
body, err := ioutil.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Error("body: %+v", body)
|
||||||
|
|
||||||
|
// Parse params from JSON.
|
||||||
|
if err := json.Unmarshal(body, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendJSON response.
|
||||||
|
func SendJSON(w http.ResponseWriter, statusCode int, v interface{}) {
|
||||||
|
buf, err := json.Marshal(Envelope{
|
||||||
|
Data: v,
|
||||||
|
StatusCode: statusCode,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
w.Write(buf)
|
||||||
|
}
|
124
pkg/controller/api/likes.go
Normal file
124
pkg/controller/api/likes.go
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Likes API.
|
||||||
|
func Likes() http.HandlerFunc {
|
||||||
|
// Request JSON schema.
|
||||||
|
type Request struct {
|
||||||
|
TableName string `json:"name"`
|
||||||
|
TableID uint64 `json:"id"`
|
||||||
|
Unlike bool `json:"unlike,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response JSON schema.
|
||||||
|
type Response struct {
|
||||||
|
OK bool `json:"OK"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Likes int64 `json:"likes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
SendJSON(w, http.StatusNotAcceptable, Response{
|
||||||
|
Error: "POST method only",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current user.
|
||||||
|
currentUser, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
SendJSON(w, http.StatusBadRequest, Response{
|
||||||
|
Error: "Couldn't get current user!",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request payload.
|
||||||
|
var req Request
|
||||||
|
if err := ParseJSON(r, &req); err != nil {
|
||||||
|
SendJSON(w, http.StatusBadRequest, Response{
|
||||||
|
Error: fmt.Sprintf("Error with request payload: %s", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Who do we notify about this like?
|
||||||
|
var targetUser *models.User
|
||||||
|
switch req.TableName {
|
||||||
|
case "photos":
|
||||||
|
if photo, err := models.GetPhoto(req.TableID); err == nil {
|
||||||
|
if user, err := models.GetUser(photo.UserID); err == nil {
|
||||||
|
targetUser = user
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Error("For like on photos table: didn't find photo %d: %s", req.TableID, err)
|
||||||
|
}
|
||||||
|
case "users":
|
||||||
|
log.Error("subject is users, find %d", req.TableID)
|
||||||
|
if user, err := models.GetUser(req.TableID); err == nil {
|
||||||
|
targetUser = user
|
||||||
|
log.Warn("found user %s", targetUser.Username)
|
||||||
|
} else {
|
||||||
|
log.Error("For like on users table: didn't find user %d: %s", req.TableID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is the table likeable?
|
||||||
|
if _, ok := models.LikeableTables[req.TableName]; !ok {
|
||||||
|
SendJSON(w, http.StatusBadRequest, Response{
|
||||||
|
Error: fmt.Sprintf("Can't like table %s: not allowed.", req.TableName),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put in a like.
|
||||||
|
if req.Unlike {
|
||||||
|
if err := models.Unlike(currentUser, req.TableName, req.TableID); err != nil {
|
||||||
|
SendJSON(w, http.StatusBadRequest, Response{
|
||||||
|
Error: fmt.Sprintf("Error unliking: %s", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the target's notification about this like.
|
||||||
|
models.RemoveNotification(req.TableName, req.TableID)
|
||||||
|
} else {
|
||||||
|
if err := models.AddLike(currentUser, req.TableName, req.TableID); err != nil {
|
||||||
|
SendJSON(w, http.StatusBadRequest, Response{
|
||||||
|
Error: fmt.Sprintf("Error liking: %s", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the recipient of the like.
|
||||||
|
log.Info("Added like on %s:%d, notifying owner %+v", req.TableName, req.TableID, targetUser)
|
||||||
|
if targetUser != nil {
|
||||||
|
notif := &models.Notification{
|
||||||
|
UserID: targetUser.ID,
|
||||||
|
User: *currentUser,
|
||||||
|
Type: models.NotificationLike,
|
||||||
|
TableName: req.TableName,
|
||||||
|
TableID: req.TableID,
|
||||||
|
}
|
||||||
|
if err := models.CreateNotification(notif); err != nil {
|
||||||
|
log.Error("Couldn't create Likes notification: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send success response.
|
||||||
|
SendJSON(w, http.StatusOK, Response{
|
||||||
|
OK: true,
|
||||||
|
Likes: models.CountLikes(req.TableName, req.TableID),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
76
pkg/controller/api/read_notification.go
Normal file
76
pkg/controller/api/read_notification.go
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReadNotification API to mark a notif ID as "read."
|
||||||
|
func ReadNotification() http.HandlerFunc {
|
||||||
|
// Request JSON schema.
|
||||||
|
type Request struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response JSON schema.
|
||||||
|
type Response struct {
|
||||||
|
OK bool `json:"OK"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
SendJSON(w, http.StatusNotAcceptable, Response{
|
||||||
|
Error: "POST method only",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current user.
|
||||||
|
currentUser, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
SendJSON(w, http.StatusBadRequest, Response{
|
||||||
|
Error: "Couldn't get current user!",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request payload.
|
||||||
|
var req Request
|
||||||
|
if err := ParseJSON(r, &req); err != nil {
|
||||||
|
SendJSON(w, http.StatusBadRequest, Response{
|
||||||
|
Error: fmt.Sprintf("Error with request payload: %s", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get this notification.
|
||||||
|
notif, err := models.GetNotification(req.ID)
|
||||||
|
if err != nil {
|
||||||
|
SendJSON(w, http.StatusInternalServerError, Response{
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure it's ours to read.
|
||||||
|
if notif.UserID != currentUser.ID {
|
||||||
|
SendJSON(w, http.StatusForbidden, Response{
|
||||||
|
Error: "That is not your notification.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark it read.
|
||||||
|
notif.Read = true
|
||||||
|
notif.Save()
|
||||||
|
|
||||||
|
// Send success response.
|
||||||
|
SendJSON(w, http.StatusOK, Response{
|
||||||
|
OK: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
139
pkg/controller/forum/add_edit.go
Normal file
139
pkg/controller/forum/add_edit.go
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
package forum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddEdit page.
|
||||||
|
func AddEdit() http.HandlerFunc {
|
||||||
|
tmpl := templates.Must("forum/add_edit.html")
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Are we editing a forum or creating a new one?
|
||||||
|
var editID uint64
|
||||||
|
if editStr := r.FormValue("id"); editStr != "" {
|
||||||
|
if i, err := strconv.Atoi(editStr); err == nil {
|
||||||
|
editID = uint64(i)
|
||||||
|
} else {
|
||||||
|
session.FlashError(w, r, "Edit parameter: id was not an integer")
|
||||||
|
templates.Redirect(w, "/forum/admin")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// If editing, look up the existing forum.
|
||||||
|
var forum *models.Forum
|
||||||
|
if editID > 0 {
|
||||||
|
if found, err := models.GetForum(editID); err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't get forum: %s", err)
|
||||||
|
templates.Redirect(w, "/forum/admin")
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// Do we have permission?
|
||||||
|
if found.OwnerID != currentUser.ID && !currentUser.IsAdmin {
|
||||||
|
templates.ForbiddenPage(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
forum = found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saving?
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
var (
|
||||||
|
title = strings.TrimSpace(r.PostFormValue("title"))
|
||||||
|
fragment = strings.TrimSpace(strings.ToLower(r.PostFormValue("fragment")))
|
||||||
|
description = strings.TrimSpace(r.PostFormValue("description"))
|
||||||
|
category = strings.TrimSpace(r.PostFormValue("category"))
|
||||||
|
isExplicit = r.PostFormValue("explicit") == "true"
|
||||||
|
isPrivileged = r.PostFormValue("privileged") == "true"
|
||||||
|
isPermitPhotos = r.PostFormValue("permit_photos") == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sanity check admin-only settings.
|
||||||
|
if !currentUser.IsAdmin {
|
||||||
|
isPrivileged = false
|
||||||
|
isPermitPhotos = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Were we editing an existing forum?
|
||||||
|
if forum != nil {
|
||||||
|
forum.Title = title
|
||||||
|
forum.Description = description
|
||||||
|
forum.Category = category
|
||||||
|
forum.Explicit = isExplicit
|
||||||
|
forum.Privileged = isPrivileged
|
||||||
|
forum.PermitPhotos = isPermitPhotos
|
||||||
|
|
||||||
|
// Save it.
|
||||||
|
if err := forum.Save(); err == nil {
|
||||||
|
session.Flash(w, r, "Forum has been updated!")
|
||||||
|
templates.Redirect(w, "/forum/admin")
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
session.FlashError(w, r, "Error saving the forum: %s", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Validate the fragment. Front-end enforces the pattern so this
|
||||||
|
// is just a sanity check.
|
||||||
|
if m := FragmentRegexp.FindStringSubmatch(fragment); m == nil {
|
||||||
|
session.FlashError(w, r, "The fragment format is invalid.")
|
||||||
|
templates.Redirect(w, "/forum/admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the fragment is unique.
|
||||||
|
if _, err := models.ForumByFragment(fragment); err == nil {
|
||||||
|
session.FlashError(w, r, "The forum fragment is already in use.")
|
||||||
|
} else {
|
||||||
|
// Create the forum.
|
||||||
|
forum = &models.Forum{
|
||||||
|
Owner: *currentUser,
|
||||||
|
Category: category,
|
||||||
|
Fragment: fragment,
|
||||||
|
Title: title,
|
||||||
|
Description: description,
|
||||||
|
Explicit: isExplicit,
|
||||||
|
Privileged: isPrivileged,
|
||||||
|
PermitPhotos: isPermitPhotos,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := models.CreateForum(forum); err == nil {
|
||||||
|
session.Flash(w, r, "The forum has been created!")
|
||||||
|
templates.Redirect(w, "/forum/admin")
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
session.FlashError(w, r, "Error creating the forum: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = editID
|
||||||
|
|
||||||
|
var vars = map[string]interface{}{
|
||||||
|
"EditID": editID,
|
||||||
|
"EditForum": forum,
|
||||||
|
"Categories": config.ForumCategories,
|
||||||
|
}
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
85
pkg/controller/forum/forum.go
Normal file
85
pkg/controller/forum/forum.go
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
package forum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Forum view for a specific board index.
|
||||||
|
func Forum() http.HandlerFunc {
|
||||||
|
tmpl := templates.Must("forum/board_index.html")
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Parse the path parameters
|
||||||
|
var (
|
||||||
|
forum *models.Forum
|
||||||
|
)
|
||||||
|
|
||||||
|
if m := ForumPathRegexp.FindStringSubmatch(r.URL.Path); m == nil {
|
||||||
|
log.Error("Regexp failed to parse: %s", r.URL.Path)
|
||||||
|
templates.NotFoundPage(w, r)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// Look up the forum itself.
|
||||||
|
if found, err := models.ForumByFragment(m[1]); err != nil {
|
||||||
|
templates.NotFoundPage(w, r)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
forum = found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the pinned threads.
|
||||||
|
pinned, err := models.PinnedThreads(forum)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't get pinned threads: %s", err)
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all the categorized index forums.
|
||||||
|
// XXX: we get a large page size to get ALL official forums
|
||||||
|
var pager = &models.Pagination{
|
||||||
|
Page: 1,
|
||||||
|
PerPage: config.PageSizeThreadList,
|
||||||
|
Sort: "updated_at desc",
|
||||||
|
}
|
||||||
|
pager.ParsePage(r)
|
||||||
|
|
||||||
|
threads, err := models.PaginateThreads(currentUser, forum, pager)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't paginate threads: %s", err)
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject pinned threads on top.
|
||||||
|
threads = append(pinned, threads...)
|
||||||
|
|
||||||
|
// Map the statistics (replies, views) of these threads.
|
||||||
|
threadMap := models.MapThreadStatistics(threads)
|
||||||
|
|
||||||
|
var vars = map[string]interface{}{
|
||||||
|
"Forum": forum,
|
||||||
|
"Threads": threads,
|
||||||
|
"ThreadMap": threadMap,
|
||||||
|
"Pager": pager,
|
||||||
|
}
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
77
pkg/controller/forum/forums.go
Normal file
77
pkg/controller/forum/forums.go
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
package forum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Regular expressions
|
||||||
|
var (
|
||||||
|
FragmentPattern = `[a-z0-9._-]{1,30}`
|
||||||
|
FragmentRegexp = regexp.MustCompile(
|
||||||
|
fmt.Sprintf(`^(%s)$`, FragmentPattern),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Forum path parameters.
|
||||||
|
ForumPathRegexp = regexp.MustCompile(
|
||||||
|
fmt.Sprintf(`^/f/(%s)`, FragmentPattern),
|
||||||
|
)
|
||||||
|
ForumPostRegexp = regexp.MustCompile(
|
||||||
|
fmt.Sprintf(`^/f/(%s)/(post)`, FragmentPattern),
|
||||||
|
)
|
||||||
|
ForumThreadRegexp = regexp.MustCompile(
|
||||||
|
fmt.Sprintf(`^/f/(%s)/(thread)/(\d+)`, FragmentPattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Landing page for forums.
|
||||||
|
func Landing() http.HandlerFunc {
|
||||||
|
tmpl := templates.Must("forum/index.html")
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all the categorized index forums.
|
||||||
|
// XXX: we get a large page size to get ALL official forums
|
||||||
|
var pager = &models.Pagination{
|
||||||
|
Page: 1,
|
||||||
|
PerPage: config.PageSizeForums,
|
||||||
|
Sort: "title asc",
|
||||||
|
}
|
||||||
|
pager.ParsePage(r)
|
||||||
|
|
||||||
|
forums, err := models.PaginateForums(currentUser, config.ForumCategories, pager)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bucket the forums into their categories for easy front-end.
|
||||||
|
categorized := models.CategorizeForums(forums, config.ForumCategories)
|
||||||
|
|
||||||
|
// Map statistics for these forums.
|
||||||
|
forumMap := models.MapForumStatistics(forums)
|
||||||
|
|
||||||
|
var vars = map[string]interface{}{
|
||||||
|
"Pager": pager,
|
||||||
|
"Categories": categorized,
|
||||||
|
"ForumMap": forumMap,
|
||||||
|
}
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
48
pkg/controller/forum/manage.go
Normal file
48
pkg/controller/forum/manage.go
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
package forum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manage page for forums -- admin only for now but may open up later.
|
||||||
|
func Manage() http.HandlerFunc {
|
||||||
|
tmpl := templates.Must("forum/admin.html")
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get forums the user owns or can manage.
|
||||||
|
var pager = &models.Pagination{
|
||||||
|
Page: 1,
|
||||||
|
PerPage: config.PageSizeForumAdmin,
|
||||||
|
Sort: "updated_at desc",
|
||||||
|
}
|
||||||
|
pager.ParsePage(r)
|
||||||
|
|
||||||
|
forums, err := models.PaginateOwnedForums(currentUser.ID, currentUser.IsAdmin, pager)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't paginate owned forums: %s", err)
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var vars = map[string]interface{}{
|
||||||
|
"Pager": pager,
|
||||||
|
"Forums": forums,
|
||||||
|
}
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
206
pkg/controller/forum/new_post.go
Normal file
206
pkg/controller/forum/new_post.go
Normal file
|
@ -0,0 +1,206 @@
|
||||||
|
package forum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewPost view.
|
||||||
|
func NewPost() http.HandlerFunc {
|
||||||
|
tmpl := templates.Must("forum/new_post.html")
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Query params.
|
||||||
|
var (
|
||||||
|
fragment = r.FormValue("to") // forum to (new post)
|
||||||
|
toThreadID = r.FormValue("thread") // add reply to a thread ID
|
||||||
|
quoteCommentID = r.FormValue("quote") // add reply to thread while quoting a comment
|
||||||
|
editCommentID = r.FormValue("edit") // edit your comment
|
||||||
|
intent = r.FormValue("intent") // preview or submit
|
||||||
|
title = r.FormValue("title") // for new forum post only
|
||||||
|
message = r.PostFormValue("message") // comment body
|
||||||
|
isPinned = r.PostFormValue("pinned") == "true" // owners or admins only
|
||||||
|
isExplicit = r.PostFormValue("explicit") == "true" // for thread only
|
||||||
|
isNoReply = r.PostFormValue("noreply") == "true" // for thread only
|
||||||
|
isDelete = r.FormValue("delete") == "true" // delete comment (along with edit=$id)
|
||||||
|
forum *models.Forum
|
||||||
|
thread *models.Thread // if replying to a thread
|
||||||
|
comment *models.Comment // if editing a comment
|
||||||
|
|
||||||
|
// If we are modifying a comment (post) and it's the OG post of the
|
||||||
|
// thread, we show and accept the thread settings to be updated as
|
||||||
|
// well (pinned, explicit, noreply)
|
||||||
|
isOriginalComment bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the forum itself.
|
||||||
|
if found, err := models.ForumByFragment(fragment); err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't post to forum %s: not found.", fragment)
|
||||||
|
templates.Redirect(w, "/forum")
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
forum = found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Are we manipulating a reply to an existing thread?
|
||||||
|
if len(toThreadID) > 0 {
|
||||||
|
if i, err := strconv.Atoi(toThreadID); err == nil {
|
||||||
|
if found, err := models.GetThread(uint64(i)); err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't find that thread ID!")
|
||||||
|
templates.Redirect(w, fmt.Sprintf("/f/%s", forum.Fragment))
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
thread = found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Are we pre-filling the message with a quotation of an existing comment?
|
||||||
|
if len(quoteCommentID) > 0 {
|
||||||
|
if i, err := strconv.Atoi(quoteCommentID); err == nil {
|
||||||
|
if comment, err := models.GetComment(uint64(i)); err == nil {
|
||||||
|
message = markdown.Quotify(comment.Message) + "\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
if currentUser.ID != comment.UserID && !currentUser.IsAdmin {
|
||||||
|
templates.ForbiddenPage(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the form w/ the content of this message.
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
message = comment.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is this the OG thread of the post?
|
||||||
|
if thread.CommentID == comment.ID {
|
||||||
|
isOriginalComment = true
|
||||||
|
|
||||||
|
// Restore the checkbox option form values from thread settings.
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
isPinned = thread.Pinned
|
||||||
|
isExplicit = thread.Explicit
|
||||||
|
isNoReply = thread.NoReply
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Are we DELETING this comment?
|
||||||
|
if isDelete {
|
||||||
|
if err := thread.DeleteReply(comment); err != nil {
|
||||||
|
session.FlashError(w, r, "Error deleting your post: %s", err)
|
||||||
|
} else {
|
||||||
|
session.Flash(w, r, "Your post has been deleted.")
|
||||||
|
}
|
||||||
|
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||||
|
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
|
||||||
|
|
||||||
|
// Can we update the thread props?
|
||||||
|
if isOriginalComment {
|
||||||
|
thread.Pinned = isPinned
|
||||||
|
thread.Explicit = isExplicit
|
||||||
|
thread.NoReply = isNoReply
|
||||||
|
if err := thread.Save(); err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't save thread properties: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Are we replying to an existing thread?
|
||||||
|
if thread != nil {
|
||||||
|
if _, err := thread.Reply(currentUser, message); err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't add reply to thread: %s", err)
|
||||||
|
} else {
|
||||||
|
session.Flash(w, r, "Reply added to the thread!")
|
||||||
|
}
|
||||||
|
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new thread?
|
||||||
|
if thread, err := models.CreateThread(
|
||||||
|
currentUser,
|
||||||
|
forum.ID,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
isPinned,
|
||||||
|
isExplicit,
|
||||||
|
isNoReply,
|
||||||
|
); err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't create thread: %s", err)
|
||||||
|
} else {
|
||||||
|
session.Flash(w, r, "Thread created!")
|
||||||
|
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var vars = map[string]interface{}{
|
||||||
|
"Forum": forum,
|
||||||
|
"Thread": thread,
|
||||||
|
"Intent": intent,
|
||||||
|
"PostTitle": title,
|
||||||
|
"EditCommentID": editCommentID,
|
||||||
|
"EditThreadSettings": isOriginalComment,
|
||||||
|
"Message": message,
|
||||||
|
|
||||||
|
// Thread settings (for editing the original comment esp.)
|
||||||
|
"IsPinned": isPinned,
|
||||||
|
"IsExplicit": isExplicit,
|
||||||
|
"IsNoReply": isNoReply,
|
||||||
|
}
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
88
pkg/controller/forum/thread.go
Normal file
88
pkg/controller/forum/thread.go
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
package forum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ThreadPathRegexp = regexp.MustCompile(`^/forum/thread/(\d+)$`)
|
||||||
|
|
||||||
|
// Thread view for a specific board index.
|
||||||
|
func Thread() http.HandlerFunc {
|
||||||
|
tmpl := templates.Must("forum/thread.html")
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Parse the path parameters
|
||||||
|
var (
|
||||||
|
forum *models.Forum
|
||||||
|
thread *models.Thread
|
||||||
|
)
|
||||||
|
|
||||||
|
if m := ThreadPathRegexp.FindStringSubmatch(r.URL.Path); m == nil {
|
||||||
|
log.Error("Regexp failed to parse: %s", r.URL.Path)
|
||||||
|
templates.NotFoundPage(w, r)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
if threadID, err := strconv.Atoi(m[1]); err != nil {
|
||||||
|
session.FlashError(w, r, "Invalid thread ID in the address bar.")
|
||||||
|
templates.Redirect(w, "/forum")
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// Load the thread.
|
||||||
|
if found, err := models.GetThread(uint64(threadID)); err != nil {
|
||||||
|
session.FlashError(w, r, "That thread does not exist.")
|
||||||
|
templates.Redirect(w, "/forum")
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
thread = found
|
||||||
|
forum = &thread.Forum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping the view count on this thread.
|
||||||
|
if err := thread.View(); err != nil {
|
||||||
|
log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paginate the comments on this thread.
|
||||||
|
var pager = &models.Pagination{
|
||||||
|
Page: 1,
|
||||||
|
PerPage: config.PageSizeThreadList,
|
||||||
|
Sort: "created_at asc",
|
||||||
|
}
|
||||||
|
pager.ParsePage(r)
|
||||||
|
|
||||||
|
comments, err := models.PaginateComments(currentUser, "threads", thread.ID, pager)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't paginate comments: %s", err)
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var vars = map[string]interface{}{
|
||||||
|
"Forum": forum,
|
||||||
|
"Thread": thread,
|
||||||
|
"Comments": comments,
|
||||||
|
"Pager": pager,
|
||||||
|
}
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -65,6 +65,15 @@ func Contact() http.HandlerFunc {
|
||||||
case "report.message":
|
case "report.message":
|
||||||
tableName = "messages"
|
tableName = "messages"
|
||||||
tableLabel = "Direct Message conversation"
|
tableLabel = "Direct Message conversation"
|
||||||
|
case "report.comment":
|
||||||
|
tableName = "comments"
|
||||||
|
|
||||||
|
// Find this comment.
|
||||||
|
if comment, err := models.GetComment(uint64(tableID)); err == nil {
|
||||||
|
tableLabel = fmt.Sprintf(`A comment written by "%s"`, comment.User.Username)
|
||||||
|
} else {
|
||||||
|
log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -46,10 +46,18 @@ func SiteGallery() http.HandlerFunc {
|
||||||
session.FlashError(w, r, "Failed to MapUsers: %s", err)
|
session.FlashError(w, r, "Failed to MapUsers: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get Likes information about these photos.
|
||||||
|
var photoIDs = []uint64{}
|
||||||
|
for _, p := range photos {
|
||||||
|
photoIDs = append(photoIDs, p.ID)
|
||||||
|
}
|
||||||
|
likeMap := models.MapLikes(currentUser, "photos", photoIDs)
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"IsSiteGallery": true,
|
"IsSiteGallery": true,
|
||||||
"Photos": photos,
|
"Photos": photos,
|
||||||
"UserMap": userMap,
|
"UserMap": userMap,
|
||||||
|
"LikeMap": likeMap,
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
"ViewStyle": viewStyle,
|
"ViewStyle": viewStyle,
|
||||||
}
|
}
|
||||||
|
|
|
@ -91,11 +91,19 @@ func UserPhotos() http.HandlerFunc {
|
||||||
explicitCount, _ = models.CountExplicitPhotos(user.ID, visibility)
|
explicitCount, _ = models.CountExplicitPhotos(user.ID, visibility)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get Likes information about these photos.
|
||||||
|
var photoIDs = []uint64{}
|
||||||
|
for _, p := range photos {
|
||||||
|
photoIDs = append(photoIDs, p.ID)
|
||||||
|
}
|
||||||
|
likeMap := models.MapLikes(currentUser, "photos", photoIDs)
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"IsOwnPhotos": currentUser.ID == user.ID,
|
"IsOwnPhotos": currentUser.ID == user.ID,
|
||||||
"User": user,
|
"User": user,
|
||||||
"Photos": photos,
|
"Photos": photos,
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
|
"LikeMap": likeMap,
|
||||||
"ViewStyle": viewStyle,
|
"ViewStyle": viewStyle,
|
||||||
"ExplicitCount": explicitCount,
|
"ExplicitCount": explicitCount,
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
package markdown
|
package markdown
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
"github.com/shurcooL/github_flavored_markdown"
|
"github.com/shurcooL/github_flavored_markdown"
|
||||||
)
|
)
|
||||||
|
@ -16,3 +18,12 @@ func Render(input string) string {
|
||||||
safened := p.SanitizeBytes(html)
|
safened := p.SanitizeBytes(html)
|
||||||
return string(safened)
|
return string(safened)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quotify a message putting it into a Markdown "> quotes" block.
|
||||||
|
func Quotify(input string) string {
|
||||||
|
var lines = []string{}
|
||||||
|
for _, line := range strings.Split(input, "\n") {
|
||||||
|
lines = append(lines, "> "+line)
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
|
@ -18,6 +18,12 @@ func CSRF(handler http.Handler) http.Handler {
|
||||||
token := MakeCSRFCookie(r, w)
|
token := MakeCSRFCookie(r, w)
|
||||||
ctx := context.WithValue(r.Context(), session.CSRFKey, token)
|
ctx := context.WithValue(r.Context(), session.CSRFKey, token)
|
||||||
|
|
||||||
|
// If it's a JSON post, allow it thru.
|
||||||
|
if r.Header.Get("Content-Type") == "application/json" {
|
||||||
|
handler.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// If we are running a POST request, validate the CSRF form value.
|
// If we are running a POST request, validate the CSRF form value.
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
r.ParseMultipartForm(config.MultipartMaxMemory)
|
r.ParseMultipartForm(config.MultipartMaxMemory)
|
||||||
|
|
70
pkg/models/comment.go
Normal file
70
pkg/models/comment.go
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Comment table - in forum threads, on profiles or photos, etc.
|
||||||
|
type Comment struct {
|
||||||
|
ID uint64 `gorm:"primaryKey"`
|
||||||
|
TableName string `gorm:"index"`
|
||||||
|
TableID uint64 `gorm:"index"`
|
||||||
|
UserID uint64 `gorm:"index"`
|
||||||
|
User User
|
||||||
|
Message string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload related tables for the forum (classmethod).
|
||||||
|
func (c *Comment) Preload() *gorm.DB {
|
||||||
|
return DB.Preload("User.ProfilePhoto")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetComment by ID.
|
||||||
|
func GetComment(id uint64) (*Comment, error) {
|
||||||
|
c := &Comment{}
|
||||||
|
result := c.Preload().First(&c, id)
|
||||||
|
return c, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddComment about anything.
|
||||||
|
func AddComment(user *User, tableName string, tableID uint64, message string) (*Comment, error) {
|
||||||
|
c := &Comment{
|
||||||
|
TableName: tableName,
|
||||||
|
TableID: tableID,
|
||||||
|
User: *user,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
result := DB.Create(c)
|
||||||
|
return c, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaginateComments provides a page of comments on something.
|
||||||
|
func PaginateComments(user *User, tableName string, tableID uint64, pager *Pagination) ([]*Comment, error) {
|
||||||
|
var (
|
||||||
|
cs = []*Comment{}
|
||||||
|
query = (&Comment{}).Preload()
|
||||||
|
)
|
||||||
|
|
||||||
|
query = query.Where(
|
||||||
|
"table_name = ? AND table_id = ?",
|
||||||
|
tableName, tableID,
|
||||||
|
).Order(pager.Sort)
|
||||||
|
|
||||||
|
query.Model(&Comment{}).Count(&pager.Total)
|
||||||
|
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&cs)
|
||||||
|
return cs, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save a comment.
|
||||||
|
func (c *Comment) Save() error {
|
||||||
|
return DB.Save(c).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a comment.
|
||||||
|
func (c *Comment) Delete() error {
|
||||||
|
return DB.Delete(c).Error
|
||||||
|
}
|
|
@ -19,6 +19,10 @@ func DeleteUser(user *models.User) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
var todo = []remover{
|
var todo = []remover{
|
||||||
|
{"Notifications", DeleteNotifications},
|
||||||
|
{"Likes", DeleteLikes},
|
||||||
|
{"Threads", DeleteForumThreads},
|
||||||
|
{"Comments", DeleteComments},
|
||||||
{"Photos", DeleteUserPhotos},
|
{"Photos", DeleteUserPhotos},
|
||||||
{"Certification Photo", DeleteCertification},
|
{"Certification Photo", DeleteCertification},
|
||||||
{"Messages", DeleteUserMessages},
|
{"Messages", DeleteUserMessages},
|
||||||
|
@ -112,6 +116,26 @@ func DeleteFriends(userID uint64) error {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteNotifications scrubs all notifications about a user.
|
||||||
|
func DeleteNotifications(userID uint64) error {
|
||||||
|
log.Error("DeleteUser: DeleteNotifications(%d)", userID)
|
||||||
|
result := models.DB.Where(
|
||||||
|
"user_id = ? OR about_user_id = ?",
|
||||||
|
userID, userID,
|
||||||
|
).Delete(&models.Notification{})
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLikes scrubs all Likes about a user.
|
||||||
|
func DeleteLikes(userID uint64) error {
|
||||||
|
log.Error("DeleteUser: DeleteLikes(%d)", userID)
|
||||||
|
result := models.DB.Where(
|
||||||
|
"user_id = ? OR (table_name='users' AND table_id=?)",
|
||||||
|
userID, userID,
|
||||||
|
).Delete(&models.Like{})
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteProfile scrubs data for deleting a user.
|
// DeleteProfile scrubs data for deleting a user.
|
||||||
func DeleteProfile(userID uint64) error {
|
func DeleteProfile(userID uint64) error {
|
||||||
log.Error("DeleteUser: DeleteProfile(%d)", userID)
|
log.Error("DeleteUser: DeleteProfile(%d)", userID)
|
||||||
|
@ -121,3 +145,57 @@ func DeleteProfile(userID uint64) error {
|
||||||
).Delete(&models.ProfileField{})
|
).Delete(&models.ProfileField{})
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteForumThreads scrubs all forum threads started by the user.
|
||||||
|
func DeleteForumThreads(userID uint64) error {
|
||||||
|
log.Error("DeleteUser: DeleteForumThreads(%d)", userID)
|
||||||
|
|
||||||
|
var threadIDs = []uint64{}
|
||||||
|
result := models.DB.Table(
|
||||||
|
"threads",
|
||||||
|
).Joins(
|
||||||
|
"JOIN comments ON (threads.comment_id = comments.id)",
|
||||||
|
).Select(
|
||||||
|
"distinct(threads.id) as id",
|
||||||
|
).Where(
|
||||||
|
"comments.user_id = ?",
|
||||||
|
userID,
|
||||||
|
).Scan(&threadIDs)
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
return fmt.Errorf("Couldn't list thread IDs created by user: %s", result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Warn("thread IDs to wipe: %+v", threadIDs)
|
||||||
|
|
||||||
|
// Wipe all these threads and their comments.
|
||||||
|
if len(threadIDs) > 0 {
|
||||||
|
result = models.DB.Where(
|
||||||
|
"table_name = ? AND table_id IN ?",
|
||||||
|
"threads", threadIDs,
|
||||||
|
).Delete(&models.Comment{})
|
||||||
|
if result.Error != nil {
|
||||||
|
return fmt.Errorf("Couldn't wipe threads of comments: %s", result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And finish the threads off too.
|
||||||
|
result = models.DB.Where(
|
||||||
|
"id IN ?",
|
||||||
|
threadIDs,
|
||||||
|
).Delete(&models.Thread{})
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteComments deletes all comments by the user.
|
||||||
|
func DeleteComments(userID uint64) error {
|
||||||
|
log.Error("DeleteUser: DeleteComments(%d)", userID)
|
||||||
|
|
||||||
|
result := models.DB.Where(
|
||||||
|
"user_id = ?",
|
||||||
|
userID,
|
||||||
|
).Delete(&models.Comment{})
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
161
pkg/models/forum.go
Normal file
161
pkg/models/forum.go
Normal file
|
@ -0,0 +1,161 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Forum table.
|
||||||
|
type Forum struct {
|
||||||
|
ID uint64 `gorm:"primaryKey"`
|
||||||
|
OwnerID uint64 `gorm:"index"`
|
||||||
|
Owner User `gorm:"foreignKey:owner_id"`
|
||||||
|
Category string `gorm:"index"`
|
||||||
|
Fragment string `gorm:"uniqueIndex"`
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Explicit bool `gorm:"index"`
|
||||||
|
Privileged bool
|
||||||
|
PermitPhotos bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload related tables for the forum (classmethod).
|
||||||
|
func (f *Forum) Preload() *gorm.DB {
|
||||||
|
return DB.Preload("Owner")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetForum by ID.
|
||||||
|
func GetForum(id uint64) (*Forum, error) {
|
||||||
|
forum := &Forum{}
|
||||||
|
result := forum.Preload().First(&forum, id)
|
||||||
|
return forum, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumByFragment looks up a forum by its URL fragment.
|
||||||
|
func ForumByFragment(fragment string) (*Forum, error) {
|
||||||
|
if fragment == "" {
|
||||||
|
return nil, errors.New("the URL fragment is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
f = &Forum{}
|
||||||
|
result = f.Preload().Where(
|
||||||
|
"fragment = ?",
|
||||||
|
fragment,
|
||||||
|
).First(&f)
|
||||||
|
)
|
||||||
|
|
||||||
|
return f, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
PaginateForums scans over the available forums for a user.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- userID: of who is looking
|
||||||
|
- categories: optional, filter within categories
|
||||||
|
- pager
|
||||||
|
*/
|
||||||
|
func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Forum, error) {
|
||||||
|
var (
|
||||||
|
fs = []*Forum{}
|
||||||
|
query = (&Forum{}).Preload()
|
||||||
|
wheres = []string{}
|
||||||
|
placeholders = []interface{}{}
|
||||||
|
)
|
||||||
|
|
||||||
|
if categories != nil && len(categories) > 0 {
|
||||||
|
wheres = append(wheres, "category IN ?")
|
||||||
|
placeholders = append(placeholders, categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide explicit forum if user hasn't opted into it.
|
||||||
|
if !user.Explicit && !user.IsAdmin {
|
||||||
|
wheres = append(wheres, "explicit = false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filters?
|
||||||
|
if len(wheres) > 0 {
|
||||||
|
query = query.Where(
|
||||||
|
strings.Join(wheres, " AND "),
|
||||||
|
placeholders...,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
query = query.Order(pager.Sort)
|
||||||
|
query.Model(&Forum{}).Count(&pager.Total)
|
||||||
|
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
|
||||||
|
return fs, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaginateOwnedForums returns forums the user owns (or all forums to admins).
|
||||||
|
func PaginateOwnedForums(userID uint64, isAdmin bool, pager *Pagination) ([]*Forum, error) {
|
||||||
|
var (
|
||||||
|
fs = []*Forum{}
|
||||||
|
query = (&Forum{}).Preload()
|
||||||
|
)
|
||||||
|
|
||||||
|
if !isAdmin {
|
||||||
|
query = query.Where(
|
||||||
|
"owner_id = ?",
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
query = query.Order(pager.Sort)
|
||||||
|
query.Model(&Forum{}).Count(&pager.Total)
|
||||||
|
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
|
||||||
|
return fs, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateForum.
|
||||||
|
func CreateForum(f *Forum) error {
|
||||||
|
result := DB.Create(f)
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save a forum.
|
||||||
|
func (f *Forum) Save() error {
|
||||||
|
return DB.Save(f).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategorizedForum supports the main index page with custom categories.
|
||||||
|
type CategorizedForum struct {
|
||||||
|
Category string
|
||||||
|
Forums []*Forum
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategorizeForums buckets forums into categories for front-end.
|
||||||
|
func CategorizeForums(fs []*Forum, categories []string) []*CategorizedForum {
|
||||||
|
var (
|
||||||
|
result = []*CategorizedForum{}
|
||||||
|
idxMap = map[string]int{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize the result set.
|
||||||
|
for i, category := range categories {
|
||||||
|
result = append(result, &CategorizedForum{
|
||||||
|
Category: category,
|
||||||
|
Forums: []*Forum{},
|
||||||
|
})
|
||||||
|
idxMap[category] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bucket the forums into their categories.
|
||||||
|
for _, forum := range fs {
|
||||||
|
category := forum.Category
|
||||||
|
if category == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := idxMap[category]
|
||||||
|
result[idx].Forums = append(result[idx].Forums, forum)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
203
pkg/models/forum_stats.go
Normal file
203
pkg/models/forum_stats.go
Normal file
|
@ -0,0 +1,203 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import "git.kirsle.net/apps/gosocial/pkg/log"
|
||||||
|
|
||||||
|
// ForumStatistics queries for forum-level statistics.
|
||||||
|
type ForumStatistics struct {
|
||||||
|
RecentThread *Thread
|
||||||
|
Threads uint64
|
||||||
|
Posts uint64
|
||||||
|
Users uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForumStatsMap map[uint64]*ForumStatistics
|
||||||
|
|
||||||
|
// MapForumStatistics looks up statistics for a set of forums.
|
||||||
|
func MapForumStatistics(forums []*Forum) ForumStatsMap {
|
||||||
|
var (
|
||||||
|
result = ForumStatsMap{}
|
||||||
|
IDs = []uint64{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect forum IDs and initialize the map.
|
||||||
|
for _, forum := range forums {
|
||||||
|
IDs = append(IDs, forum.ID)
|
||||||
|
result[forum.ID] = &ForumStatistics{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather all the statistics.
|
||||||
|
result.generateThreadCount(IDs)
|
||||||
|
result.generatePostCount(IDs)
|
||||||
|
result.generateUserCount(IDs)
|
||||||
|
result.generateRecentThreads(IDs)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has stats for this thread? (we should..)
|
||||||
|
func (ts ForumStatsMap) Has(threadID uint64) bool {
|
||||||
|
_, ok := ts[threadID]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get thread stats.
|
||||||
|
func (ts ForumStatsMap) Get(threadID uint64) *ForumStatistics {
|
||||||
|
if stats, ok := ts[threadID]; ok {
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the count of threads in each of the forum 'IDs'.
|
||||||
|
func (ts ForumStatsMap) generateThreadCount(IDs []uint64) {
|
||||||
|
// Hold the result of the count/group by query.
|
||||||
|
type group struct {
|
||||||
|
ID uint64
|
||||||
|
Threads uint64
|
||||||
|
}
|
||||||
|
var groups = []group{}
|
||||||
|
|
||||||
|
// Count comments grouped by thread IDs.
|
||||||
|
err := DB.Table(
|
||||||
|
"threads",
|
||||||
|
).Select(
|
||||||
|
"forum_id AS id, count(id) AS threads",
|
||||||
|
).Where(
|
||||||
|
"forum_id IN ?",
|
||||||
|
IDs,
|
||||||
|
).Group("forum_id").Scan(&groups)
|
||||||
|
|
||||||
|
if err.Error != nil {
|
||||||
|
log.Error("MapForumStatistics: SQL error: %s", err.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the results in.
|
||||||
|
for _, row := range groups {
|
||||||
|
if stats, ok := ts[row.ID]; ok {
|
||||||
|
stats.Threads = row.Threads
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the count of all posts in each of the forum 'IDs'.
|
||||||
|
func (ts ForumStatsMap) generatePostCount(IDs []uint64) {
|
||||||
|
type group struct {
|
||||||
|
ID uint64
|
||||||
|
Posts uint64
|
||||||
|
}
|
||||||
|
var groups = []group{}
|
||||||
|
|
||||||
|
err := DB.Table(
|
||||||
|
"comments",
|
||||||
|
).Joins(
|
||||||
|
"JOIN threads ON (table_name = 'threads' AND table_id = threads.id)",
|
||||||
|
).Joins(
|
||||||
|
"JOIN forums ON (threads.forum_id = forums.id)",
|
||||||
|
).Select(
|
||||||
|
"forums.id AS id, count(comments.id) AS posts",
|
||||||
|
).Where(
|
||||||
|
`table_name = 'threads' AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM threads
|
||||||
|
WHERE table_id = threads.id
|
||||||
|
AND threads.forum_id IN ?
|
||||||
|
)`,
|
||||||
|
IDs,
|
||||||
|
).Group("forums.id").Scan(&groups)
|
||||||
|
|
||||||
|
if err.Error != nil {
|
||||||
|
log.Error("SQL error collecting posts for forum: %s", err.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the results in.
|
||||||
|
for _, row := range groups {
|
||||||
|
if stats, ok := ts[row.ID]; ok {
|
||||||
|
stats.Posts = row.Posts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the count of all users in each of the forum 'IDs'.
|
||||||
|
func (ts ForumStatsMap) generateUserCount(IDs []uint64) {
|
||||||
|
type group struct {
|
||||||
|
ForumID uint64
|
||||||
|
Users uint64
|
||||||
|
}
|
||||||
|
var groups = []group{}
|
||||||
|
|
||||||
|
err := DB.Table(
|
||||||
|
"comments",
|
||||||
|
).Joins(
|
||||||
|
"JOIN threads ON (table_name = 'threads' AND table_id = threads.id)",
|
||||||
|
).Joins(
|
||||||
|
"JOIN forums ON (threads.forum_id = forums.id)",
|
||||||
|
).Select(
|
||||||
|
"forums.id AS forum_id, count(distinct(comments.user_id)) AS users",
|
||||||
|
).Where(
|
||||||
|
"forums.id IN ?",
|
||||||
|
IDs,
|
||||||
|
).Group("forums.id").Scan(&groups)
|
||||||
|
|
||||||
|
if err.Error != nil {
|
||||||
|
log.Error("SQL error collecting users for forum: %s", err.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the results in.
|
||||||
|
for _, row := range groups {
|
||||||
|
if stats, ok := ts[row.ForumID]; ok {
|
||||||
|
stats.Users = row.Users
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the recent threads for each of the forum 'IDs'.
|
||||||
|
func (ts ForumStatsMap) generateRecentThreads(IDs []uint64) {
|
||||||
|
var threadIDs = []map[string]interface{}{}
|
||||||
|
err := DB.Table(
|
||||||
|
"threads",
|
||||||
|
).Select(
|
||||||
|
"forum_id, id AS thread_id, updated_at",
|
||||||
|
).Where(
|
||||||
|
`updated_at = (SELECT MAX(updated_at)
|
||||||
|
FROM threads t2
|
||||||
|
WHERE threads.forum_id = t2.forum_id)
|
||||||
|
AND threads.forum_id IN ?`,
|
||||||
|
IDs,
|
||||||
|
).Order(
|
||||||
|
"updated_at desc",
|
||||||
|
).Scan(&threadIDs)
|
||||||
|
|
||||||
|
if err.Error != nil {
|
||||||
|
log.Error("Getting most recent thread IDs: %s", err.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map them easier.
|
||||||
|
var (
|
||||||
|
threadForumMap = map[uint64]uint64{}
|
||||||
|
allThreadIDs = []uint64{}
|
||||||
|
)
|
||||||
|
for _, row := range threadIDs {
|
||||||
|
if row["thread_id"] == nil || row["forum_id"] == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
threadID = uint64(row["thread_id"].(int64))
|
||||||
|
forumID = uint64(row["forum_id"].(int64))
|
||||||
|
)
|
||||||
|
|
||||||
|
allThreadIDs = append(allThreadIDs, threadID)
|
||||||
|
threadForumMap[threadID] = forumID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select and map these threads in.
|
||||||
|
if threadMap, err := GetThreads(allThreadIDs); err == nil {
|
||||||
|
for threadID, thread := range threadMap {
|
||||||
|
if forumID, ok := threadForumMap[threadID]; ok {
|
||||||
|
if stats, ok := ts[forumID]; ok {
|
||||||
|
stats.RecentThread = thread
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
145
pkg/models/like.go
Normal file
145
pkg/models/like.go
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Like table.
|
||||||
|
type Like struct {
|
||||||
|
ID uint64 `gorm:"primaryKey"`
|
||||||
|
UserID uint64 `gorm:"index"` // who it belongs to
|
||||||
|
TableName string
|
||||||
|
TableID uint64
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// LikeableTables are the set of table names that allow likes (used by the JSON API).
|
||||||
|
var LikeableTables = map[string]interface{}{
|
||||||
|
"photos": nil,
|
||||||
|
"users": nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddLike to something.
|
||||||
|
func AddLike(user *User, tableName string, tableID uint64) error {
|
||||||
|
// Already has a like?
|
||||||
|
var like = &Like{}
|
||||||
|
exist := DB.Model(like).Where(
|
||||||
|
"user_id = ? AND table_name = ? AND table_id = ?",
|
||||||
|
user.ID, tableName, tableID,
|
||||||
|
).First(&like)
|
||||||
|
if exist.Error == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create it.
|
||||||
|
like = &Like{
|
||||||
|
UserID: user.ID,
|
||||||
|
TableName: tableName,
|
||||||
|
TableID: tableID,
|
||||||
|
}
|
||||||
|
return DB.Create(like).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlike something.
|
||||||
|
func Unlike(user *User, tableName string, tableID uint64) error {
|
||||||
|
result := DB.Where(
|
||||||
|
"user_id = ? AND table_name = ? AND table_id = ?",
|
||||||
|
user.ID, tableName, tableID,
|
||||||
|
).Delete(&Like{})
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountLikes on something.
|
||||||
|
func CountLikes(tableName string, tableID uint64) int64 {
|
||||||
|
var count int64
|
||||||
|
DB.Model(&Like{}).Where(
|
||||||
|
"table_name = ? AND table_id = ?",
|
||||||
|
tableName, tableID,
|
||||||
|
).Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// LikedIDs filters a set of table IDs to ones the user likes.
|
||||||
|
func LikedIDs(user *User, tableName string, tableIDs []uint64) ([]uint64, error) {
|
||||||
|
var result = []uint64{}
|
||||||
|
if r := DB.Table(
|
||||||
|
"likes",
|
||||||
|
).Select(
|
||||||
|
"table_id",
|
||||||
|
).Where(
|
||||||
|
"user_id = ? AND table_name = ? AND table_id IN ?",
|
||||||
|
user.ID, tableName, tableIDs,
|
||||||
|
).Scan(&result); r.Error != nil {
|
||||||
|
return result, r.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LikeMap maps table IDs to Likes metadata.
|
||||||
|
type LikeMap map[uint64]*LikeStats
|
||||||
|
|
||||||
|
// Get like stats from the map.
|
||||||
|
func (lm LikeMap) Get(id uint64) *LikeStats {
|
||||||
|
if stats, ok := lm[id]; ok {
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
return &LikeStats{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LikeStats holds mapped statistics about liked objects.
|
||||||
|
type LikeStats struct {
|
||||||
|
Count int64 // how many total
|
||||||
|
UserLikes bool // current user likes it
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapLikes over a set of table IDs.
|
||||||
|
func MapLikes(user *User, tableName string, tableIDs []uint64) LikeMap {
|
||||||
|
var result = LikeMap{}
|
||||||
|
|
||||||
|
// Initialize the result set.
|
||||||
|
for _, id := range tableIDs {
|
||||||
|
result[id] = &LikeStats{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hold the result of the grouped count query.
|
||||||
|
type group struct {
|
||||||
|
ID uint64
|
||||||
|
Likes int64
|
||||||
|
}
|
||||||
|
var groups = []group{}
|
||||||
|
|
||||||
|
// Map the counts of likes to each of these IDs.
|
||||||
|
if res := DB.Table(
|
||||||
|
"likes",
|
||||||
|
).Select(
|
||||||
|
"table_id AS id, count(id) AS likes",
|
||||||
|
).Where(
|
||||||
|
"table_name = ? AND table_id IN ?",
|
||||||
|
tableName, tableIDs,
|
||||||
|
).Group("table_id").Scan(&groups); res.Error != nil {
|
||||||
|
log.Error("MapLikes: count query: %s", res.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the counts back in.
|
||||||
|
for _, row := range groups {
|
||||||
|
if stats, ok := result[row.ID]; ok {
|
||||||
|
stats.Count = row.Likes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Does the CURRENT USER like any of these IDs?
|
||||||
|
if likedIDs, err := LikedIDs(user, tableName, tableIDs); err == nil {
|
||||||
|
log.Error("USER LIKES IDS: %+v", likedIDs)
|
||||||
|
for _, id := range likedIDs {
|
||||||
|
if stats, ok := result[id]; ok {
|
||||||
|
stats.UserLikes = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
|
@ -16,4 +16,9 @@ func AutoMigrate() {
|
||||||
DB.AutoMigrate(&Friend{})
|
DB.AutoMigrate(&Friend{})
|
||||||
DB.AutoMigrate(&Block{})
|
DB.AutoMigrate(&Block{})
|
||||||
DB.AutoMigrate(&Feedback{})
|
DB.AutoMigrate(&Feedback{})
|
||||||
|
DB.AutoMigrate(&Forum{})
|
||||||
|
DB.AutoMigrate(&Thread{})
|
||||||
|
DB.AutoMigrate(&Comment{})
|
||||||
|
DB.AutoMigrate(&Like{})
|
||||||
|
DB.AutoMigrate(&Notification{})
|
||||||
}
|
}
|
||||||
|
|
175
pkg/models/notification.go
Normal file
175
pkg/models/notification.go
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notification table.
|
||||||
|
type Notification struct {
|
||||||
|
ID uint64 `gorm:"primaryKey"`
|
||||||
|
UserID uint64 `gorm:"index"` // who it belongs to
|
||||||
|
AboutUserID *uint64 `form:"index"` // the other party of this notification
|
||||||
|
User User `gorm:"foreignKey:about_user_id"`
|
||||||
|
Type NotificationType // like, comment, ...
|
||||||
|
Read bool `gorm:"index"`
|
||||||
|
TableName string // on which of your tables (photos, comments, ...)
|
||||||
|
TableID uint64
|
||||||
|
Message string // text associated, e.g. copy of comment added
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload related tables for the forum (classmethod).
|
||||||
|
func (n *Notification) Preload() *gorm.DB {
|
||||||
|
return DB.Preload("User.ProfilePhoto")
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
NotificationLike NotificationType = "like"
|
||||||
|
NotificationComment = "comment"
|
||||||
|
NotificationCustom = "custom" // custom message pushed
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateNotification
|
||||||
|
func CreateNotification(n *Notification) error {
|
||||||
|
return DB.Create(n).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotification by ID.
|
||||||
|
func GetNotification(id uint64) (*Notification, error) {
|
||||||
|
var n *Notification
|
||||||
|
result := DB.Model(n).First(&n, id)
|
||||||
|
return n, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveNotification about a table ID, e.g. when removing a like.
|
||||||
|
func RemoveNotification(tableName string, tableID uint64) error {
|
||||||
|
result := DB.Where(
|
||||||
|
"table_name = ? AND table_id = ?",
|
||||||
|
tableName, tableID,
|
||||||
|
).Delete(&Notification{})
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkNotificationsRead sets all a user's notifications to read.
|
||||||
|
func MarkNotificationsRead(user *User) error {
|
||||||
|
return DB.Model(&Notification{}).Where(
|
||||||
|
"user_id = ? AND read IS NOT TRUE",
|
||||||
|
user.ID,
|
||||||
|
).Update("read", true).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUnreadNotifications gets the count of unread Notifications for a user.
|
||||||
|
func CountUnreadNotifications(userID uint64) (int64, error) {
|
||||||
|
query := DB.Where(
|
||||||
|
"user_id = ? AND read = ?",
|
||||||
|
userID,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
result := query.Model(&Notification{}).Count(&count)
|
||||||
|
return count, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaginateNotifications returns the user's notifications.
|
||||||
|
func PaginateNotifications(user *User, pager *Pagination) ([]*Notification, error) {
|
||||||
|
var ns = []*Notification{}
|
||||||
|
|
||||||
|
query := (&Notification{}).Preload().Where(
|
||||||
|
"user_id = ?",
|
||||||
|
user.ID,
|
||||||
|
).Order(
|
||||||
|
pager.Sort,
|
||||||
|
)
|
||||||
|
|
||||||
|
query.Model(&Notification{}).Count(&pager.Total)
|
||||||
|
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&ns)
|
||||||
|
return ns, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save a notification.
|
||||||
|
func (n *Notification) Save() error {
|
||||||
|
return DB.Save(n).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationBody can store remote tables mapped.
|
||||||
|
type NotificationBody struct {
|
||||||
|
PhotoID uint64
|
||||||
|
Photo *Photo
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationMap map[uint64]*NotificationBody
|
||||||
|
|
||||||
|
// Get a notification's body from the map.
|
||||||
|
func (m NotificationMap) Get(id uint64) *NotificationBody {
|
||||||
|
if body, ok := m[id]; ok {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return &NotificationBody{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapNotifications loads associated assets, like Photos, mapped to their notification ID.
|
||||||
|
func MapNotifications(ns []*Notification) NotificationMap {
|
||||||
|
var (
|
||||||
|
IDs = []uint64{}
|
||||||
|
result = NotificationMap{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect notification IDs.
|
||||||
|
for _, row := range ns {
|
||||||
|
IDs = append(IDs, row.ID)
|
||||||
|
result[row.ID] = &NotificationBody{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type scanner struct {
|
||||||
|
PhotoID uint64
|
||||||
|
NotificationID uint64
|
||||||
|
}
|
||||||
|
var scan []scanner
|
||||||
|
|
||||||
|
// Load all of these that have photos.
|
||||||
|
err := DB.Table(
|
||||||
|
"notifications",
|
||||||
|
).Joins(
|
||||||
|
"JOIN photos ON (notifications.table_name='photos' AND notifications.table_id=photos.id)",
|
||||||
|
).Select(
|
||||||
|
"photos.id AS photo_id",
|
||||||
|
"notifications.id AS notification_id",
|
||||||
|
).Where(
|
||||||
|
"notifications.id IN ?",
|
||||||
|
IDs,
|
||||||
|
).Scan(&scan)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Couldn't select photo IDs for notifications: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect and load all the photos by ID.
|
||||||
|
var photoIDs = []uint64{}
|
||||||
|
for _, row := range scan {
|
||||||
|
// Store the photo ID in the result now.
|
||||||
|
result[row.NotificationID].PhotoID = row.PhotoID
|
||||||
|
photoIDs = append(photoIDs, row.PhotoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the photos.
|
||||||
|
if len(photoIDs) > 0 {
|
||||||
|
if photos, err := GetPhotos(photoIDs); err != nil {
|
||||||
|
log.Error("Couldn't load photo IDs for notifications: %s", err)
|
||||||
|
} else {
|
||||||
|
// Marry them to their notification IDs.
|
||||||
|
for _, body := range result {
|
||||||
|
if photo, ok := photos[body.PhotoID]; ok {
|
||||||
|
body.Photo = photo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
|
@ -76,6 +76,21 @@ func GetPhoto(id uint64) (*Photo, error) {
|
||||||
return p, result.Error
|
return p, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPhotos by an array of IDs, mapped to their IDs.
|
||||||
|
func GetPhotos(IDs []uint64) (map[uint64]*Photo, error) {
|
||||||
|
var (
|
||||||
|
mp = map[uint64]*Photo{}
|
||||||
|
ps = []*Photo{}
|
||||||
|
)
|
||||||
|
|
||||||
|
result := DB.Model(&Photo{}).Where("id IN ?", IDs).Find(&ps)
|
||||||
|
for _, row := range ps {
|
||||||
|
mp[row.ID] = row
|
||||||
|
}
|
||||||
|
|
||||||
|
return mp, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
PaginateUserPhotos gets a page of photos belonging to a user ID.
|
PaginateUserPhotos gets a page of photos belonging to a user ID.
|
||||||
*/
|
*/
|
||||||
|
|
258
pkg/models/thread.go
Normal file
258
pkg/models/thread.go
Normal file
|
@ -0,0 +1,258 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Thread table - a post within a Forum.
|
||||||
|
type Thread struct {
|
||||||
|
ID uint64 `gorm:"primaryKey"`
|
||||||
|
ForumID uint64 `gorm:"index"`
|
||||||
|
Forum Forum
|
||||||
|
Pinned bool `gorm:"index"`
|
||||||
|
Explicit bool `gorm:"index"`
|
||||||
|
NoReply bool
|
||||||
|
Title string
|
||||||
|
CommentID uint64 `gorm:"index"`
|
||||||
|
Comment Comment // first comment of the thread
|
||||||
|
Views uint64
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload related tables for the forum (classmethod).
|
||||||
|
func (f *Thread) Preload() *gorm.DB {
|
||||||
|
return DB.Preload("Forum").Preload("Comment.User.ProfilePhoto")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetThread by ID.
|
||||||
|
func GetThread(id uint64) (*Thread, error) {
|
||||||
|
t := &Thread{}
|
||||||
|
result := t.Preload().First(&t, id)
|
||||||
|
return t, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetThreads queries a set of thread IDs and returns them mapped.
|
||||||
|
func GetThreads(IDs []uint64) (map[uint64]*Thread, error) {
|
||||||
|
var (
|
||||||
|
mt = map[uint64]*Thread{}
|
||||||
|
ts = []*Thread{}
|
||||||
|
)
|
||||||
|
|
||||||
|
result := (&Thread{}).Preload().Where("id IN ?", IDs).Find(&ts)
|
||||||
|
for _, row := range ts {
|
||||||
|
mt[row.ID] = row
|
||||||
|
}
|
||||||
|
|
||||||
|
return mt, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateThread creates a new thread with proper Comment structure.
|
||||||
|
func CreateThread(user *User, forumID uint64, title, message string, pinned, explicit, noReply bool) (*Thread, error) {
|
||||||
|
thread := &Thread{
|
||||||
|
ForumID: forumID,
|
||||||
|
Title: title,
|
||||||
|
Pinned: pinned,
|
||||||
|
Explicit: explicit,
|
||||||
|
NoReply: noReply && user.IsAdmin,
|
||||||
|
Comment: Comment{
|
||||||
|
User: *user,
|
||||||
|
Message: message,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Error("CreateThread: Going to post %+v", thread)
|
||||||
|
|
||||||
|
// Create the thread & comment first...
|
||||||
|
result := DB.Create(thread)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill out the Comment with proper reverse foreign keys.
|
||||||
|
thread.Comment.TableName = "threads"
|
||||||
|
thread.Comment.TableID = thread.ID
|
||||||
|
log.Error("Saving updated comment: %+v", thread)
|
||||||
|
result = DB.Save(&thread.Comment)
|
||||||
|
return thread, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply to a thread, adding an additional comment.
|
||||||
|
func (t *Thread) Reply(user *User, message string) (*Comment, error) {
|
||||||
|
// Save the thread on reply, updating its timestamp.
|
||||||
|
if err := t.Save(); err != nil {
|
||||||
|
log.Error("Thread.Reply: couldn't ping UpdatedAt on thread: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return AddComment(user, "threads", t.ID, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteReply removes a comment from a thread. If it is the primary comment, deletes the whole thread.
|
||||||
|
func (t *Thread) DeleteReply(comment *Comment) error {
|
||||||
|
// Sanity check that this reply is one of ours.
|
||||||
|
if !(comment.TableName == "threads" && comment.TableID == t.ID) {
|
||||||
|
return errors.New("that comment doesn't belong to this thread")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is this the primary comment that started the thread? If so, delete the whole thread.
|
||||||
|
if comment.ID == t.CommentID {
|
||||||
|
log.Error("DeleteReply(%d): this is the parent comment of a thread (%d '%s'), remove the whole thread", comment.ID, t.ID, t.Title)
|
||||||
|
return t.Delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove just this comment.
|
||||||
|
return comment.Delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PinnedThreads returns all pinned threads in a forum (there should generally be few of these).
|
||||||
|
func PinnedThreads(forum *Forum) ([]*Thread, error) {
|
||||||
|
var (
|
||||||
|
ts = []*Thread{}
|
||||||
|
query = (&Thread{}).Preload().Where(
|
||||||
|
"forum_id = ? AND pinned IS TRUE",
|
||||||
|
forum.ID,
|
||||||
|
).Order("updated_at desc")
|
||||||
|
)
|
||||||
|
|
||||||
|
result := query.Find(&ts)
|
||||||
|
return ts, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaginateThreads provides a forum index view of posts, minus pinned posts.
|
||||||
|
func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, error) {
|
||||||
|
var (
|
||||||
|
ts = []*Thread{}
|
||||||
|
query = (&Thread{}).Preload()
|
||||||
|
wheres = []string{}
|
||||||
|
placeholders = []interface{}{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Always filters.
|
||||||
|
wheres = append(wheres, "forum_id = ? AND pinned IS NOT TRUE")
|
||||||
|
placeholders = append(placeholders, forum.ID)
|
||||||
|
|
||||||
|
// If the user hasn't opted in for Explicit, hide NSFW threads.
|
||||||
|
if !user.Explicit && !user.IsAdmin {
|
||||||
|
wheres = append(wheres, "explicit IS NOT TRUE")
|
||||||
|
}
|
||||||
|
|
||||||
|
query = query.Where(
|
||||||
|
strings.Join(wheres, " AND "),
|
||||||
|
placeholders...,
|
||||||
|
).Order(pager.Sort)
|
||||||
|
|
||||||
|
query.Model(&Thread{}).Count(&pager.Total)
|
||||||
|
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&ts)
|
||||||
|
return ts, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// View a thread, incrementing its View count but not its UpdatedAt.
|
||||||
|
func (t *Thread) View() error {
|
||||||
|
return DB.Model(&Thread{}).Where(
|
||||||
|
"id = ?",
|
||||||
|
t.ID,
|
||||||
|
).Updates(map[string]interface{}{
|
||||||
|
"views": t.Views + 1,
|
||||||
|
"updated_at": t.UpdatedAt,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save a thread, updating its timestamp.
|
||||||
|
func (t *Thread) Save() error {
|
||||||
|
return DB.Save(t).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a thread and all of its comments.
|
||||||
|
func (t *Thread) Delete() error {
|
||||||
|
// Remove all comments.
|
||||||
|
result := DB.Where(
|
||||||
|
"table_name = ? AND table_id = ?",
|
||||||
|
"threads", t.ID,
|
||||||
|
).Delete(&Comment{})
|
||||||
|
if result.Error != nil {
|
||||||
|
return fmt.Errorf("deleting comments for thread: %s", result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the thread itself.
|
||||||
|
return DB.Delete(t).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThreadStatistics queries for reply/view count for threads.
|
||||||
|
type ThreadStatistics struct {
|
||||||
|
Replies uint64
|
||||||
|
Views uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThreadStatsMap map[uint64]*ThreadStatistics
|
||||||
|
|
||||||
|
// MapThreadStatistics looks up statistics for a set of threads.
|
||||||
|
func MapThreadStatistics(threads []*Thread) ThreadStatsMap {
|
||||||
|
var (
|
||||||
|
result = ThreadStatsMap{}
|
||||||
|
IDs = []uint64{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect thread IDs and initialize the map.
|
||||||
|
for _, thread := range threads {
|
||||||
|
IDs = append(IDs, thread.ID)
|
||||||
|
result[thread.ID] = &ThreadStatistics{
|
||||||
|
Views: thread.Views,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hold the result of the count/group by query.
|
||||||
|
type group struct {
|
||||||
|
ID uint64
|
||||||
|
Replies uint64
|
||||||
|
}
|
||||||
|
var groups = []group{}
|
||||||
|
|
||||||
|
// Count comments grouped by thread IDs.
|
||||||
|
err := DB.Table(
|
||||||
|
"comments",
|
||||||
|
).Select(
|
||||||
|
"table_id AS id, count(id) AS replies",
|
||||||
|
).Where(
|
||||||
|
"table_name = ? AND table_id IN ?",
|
||||||
|
"threads", IDs,
|
||||||
|
).Group("table_id").Scan(&groups)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Error("MapThreadStatistics: SQL error: %s")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the results in.
|
||||||
|
for _, row := range groups {
|
||||||
|
log.Error("Got row: %+v", row)
|
||||||
|
if stats, ok := result[row.ID]; ok {
|
||||||
|
stats.Replies = row.Replies
|
||||||
|
|
||||||
|
// Remove the OG comment from the count.
|
||||||
|
if stats.Replies > 0 {
|
||||||
|
stats.Replies--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has stats for this thread? (we should..)
|
||||||
|
func (ts ThreadStatsMap) Has(threadID uint64) bool {
|
||||||
|
_, ok := ts[threadID]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get thread stats.
|
||||||
|
func (ts ThreadStatsMap) Get(threadID uint64) *ThreadStatistics {
|
||||||
|
if stats, ok := ts[threadID]; ok {
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"git.kirsle.net/apps/gosocial/pkg/controller/admin"
|
"git.kirsle.net/apps/gosocial/pkg/controller/admin"
|
||||||
"git.kirsle.net/apps/gosocial/pkg/controller/api"
|
"git.kirsle.net/apps/gosocial/pkg/controller/api"
|
||||||
"git.kirsle.net/apps/gosocial/pkg/controller/block"
|
"git.kirsle.net/apps/gosocial/pkg/controller/block"
|
||||||
|
"git.kirsle.net/apps/gosocial/pkg/controller/forum"
|
||||||
"git.kirsle.net/apps/gosocial/pkg/controller/friend"
|
"git.kirsle.net/apps/gosocial/pkg/controller/friend"
|
||||||
"git.kirsle.net/apps/gosocial/pkg/controller/inbox"
|
"git.kirsle.net/apps/gosocial/pkg/controller/inbox"
|
||||||
"git.kirsle.net/apps/gosocial/pkg/controller/index"
|
"git.kirsle.net/apps/gosocial/pkg/controller/index"
|
||||||
|
@ -55,16 +56,24 @@ func New() http.Handler {
|
||||||
// Certification Required. Pages that only full (verified) members can access.
|
// Certification Required. Pages that only full (verified) members can access.
|
||||||
mux.Handle("/photo/gallery", middleware.CertRequired(photo.SiteGallery()))
|
mux.Handle("/photo/gallery", middleware.CertRequired(photo.SiteGallery()))
|
||||||
mux.Handle("/members", middleware.CertRequired(account.Search()))
|
mux.Handle("/members", middleware.CertRequired(account.Search()))
|
||||||
|
mux.Handle("/forum", middleware.CertRequired(forum.Landing()))
|
||||||
|
mux.Handle("/forum/post", middleware.CertRequired(forum.NewPost()))
|
||||||
|
mux.Handle("/forum/thread/", middleware.CertRequired(forum.Thread()))
|
||||||
|
mux.Handle("/f/", middleware.CertRequired(forum.Forum()))
|
||||||
|
|
||||||
// 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/feedback", middleware.AdminRequired(admin.Feedback()))
|
||||||
mux.Handle("/admin/user-action", middleware.AdminRequired(admin.UserActions()))
|
mux.Handle("/admin/user-action", middleware.AdminRequired(admin.UserActions()))
|
||||||
|
mux.Handle("/forum/admin", middleware.AdminRequired(forum.Manage()))
|
||||||
|
mux.Handle("/forum/admin/edit", middleware.AdminRequired(forum.AddEdit()))
|
||||||
|
|
||||||
// JSON API endpoints.
|
// JSON API endpoints.
|
||||||
mux.HandleFunc("/v1/version", api.Version())
|
mux.HandleFunc("/v1/version", api.Version())
|
||||||
mux.HandleFunc("/v1/users/me", api.LoginOK())
|
mux.HandleFunc("/v1/users/me", api.LoginOK())
|
||||||
|
mux.Handle("/v1/likes", middleware.LoginRequired(api.Likes()))
|
||||||
|
mux.Handle("/v1/notifications/read", middleware.LoginRequired(api.ReadNotification()))
|
||||||
|
|
||||||
// Static files.
|
// Static files.
|
||||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(config.StaticPath))))
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(config.StaticPath))))
|
||||||
|
|
|
@ -58,6 +58,12 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
||||||
}
|
}
|
||||||
return value[:n]
|
return value[:n]
|
||||||
},
|
},
|
||||||
|
"TrimEllipses": func(value string, n int) string {
|
||||||
|
if n > len(value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return value[:n] + "…"
|
||||||
|
},
|
||||||
"IterRange": func(start, n int) []int {
|
"IterRange": func(start, n int) []int {
|
||||||
var result = []int{}
|
var result = []int{}
|
||||||
for i := start; i <= n; i++ {
|
for i := start; i <= n; i++ {
|
||||||
|
|
|
@ -13,6 +13,8 @@ import (
|
||||||
// MergeVars mixes in globally available template variables. The http.Request is optional.
|
// MergeVars mixes in globally available template variables. The http.Request is optional.
|
||||||
func MergeVars(r *http.Request, m map[string]interface{}) {
|
func MergeVars(r *http.Request, m map[string]interface{}) {
|
||||||
m["Title"] = config.Title
|
m["Title"] = config.Title
|
||||||
|
m["BuildHash"] = config.RuntimeBuild
|
||||||
|
m["BuildDate"] = config.RuntimeBuildDate
|
||||||
m["Subtitle"] = config.Subtitle
|
m["Subtitle"] = config.Subtitle
|
||||||
m["YYYY"] = time.Now().Year()
|
m["YYYY"] = time.Now().Year()
|
||||||
|
|
||||||
|
@ -31,9 +33,10 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
|
||||||
m["SessionImpersonated"] = false
|
m["SessionImpersonated"] = false
|
||||||
|
|
||||||
// User notification counts for nav bar.
|
// User notification counts for nav bar.
|
||||||
m["NavUnreadMessages"] = 0 // New messages
|
m["NavUnreadMessages"] = 0 // New messages
|
||||||
m["NavFriendRequests"] = 0 // Friend requests
|
m["NavFriendRequests"] = 0 // Friend requests
|
||||||
m["NavTotalNotifications"] = 0 // Total of above
|
m["NavUnreadNotifications"] = 0 // general notifications
|
||||||
|
m["NavTotalNotifications"] = 0 // Total of above
|
||||||
|
|
||||||
// Admin notification counts for nav bar.
|
// Admin notification counts for nav bar.
|
||||||
m["NavCertificationPhotos"] = 0 // Cert. photos needing approval
|
m["NavCertificationPhotos"] = 0 // Cert. photos needing approval
|
||||||
|
@ -50,11 +53,21 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
|
||||||
m["LoggedIn"] = true
|
m["LoggedIn"] = true
|
||||||
m["CurrentUser"] = user
|
m["CurrentUser"] = user
|
||||||
|
|
||||||
|
// Get user recent notifications.
|
||||||
|
/*notifPager := &models.Pagination{
|
||||||
|
Page: 1,
|
||||||
|
PerPage: 10,
|
||||||
|
}
|
||||||
|
if notifs, err := models.PaginateNotifications(user, notifPager); err == nil {
|
||||||
|
m["Notifications"] = notifs
|
||||||
|
}*/
|
||||||
|
|
||||||
// Collect notification counts.
|
// Collect notification counts.
|
||||||
var (
|
var (
|
||||||
// For users
|
// For users
|
||||||
countMessages int64
|
countMessages int64
|
||||||
countFriendReqs int64
|
countFriendReqs int64
|
||||||
|
countNotifications int64
|
||||||
|
|
||||||
// For admins
|
// For admins
|
||||||
countCertPhotos int64
|
countCertPhotos int64
|
||||||
|
@ -77,6 +90,14 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
|
||||||
log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err)
|
log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Count other notifications.
|
||||||
|
if count, err := models.CountUnreadNotifications(user.ID); err == nil {
|
||||||
|
m["NavUnreadNotifications"] = count
|
||||||
|
countNotifications = count
|
||||||
|
} else {
|
||||||
|
log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Are we admin?
|
// Are we admin?
|
||||||
if user.IsAdmin {
|
if user.IsAdmin {
|
||||||
// Any pending certification photos or feedback?
|
// Any pending certification photos or feedback?
|
||||||
|
@ -90,6 +111,6 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total count for user notifications.
|
// Total count for user notifications.
|
||||||
m["NavTotalNotifications"] = countMessages + countFriendReqs + countCertPhotos + countFeedback
|
m["NavTotalNotifications"] = countMessages + countFriendReqs + countNotifications + countCertPhotos + countFeedback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
56
web/static/js/likes.js
Normal file
56
web/static/js/likes.js
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
// Like button handler.
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const red = "has-text-danger";
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
// Bind to the like buttons.
|
||||||
|
(document.querySelectorAll(".nonshy-like-button") || []).forEach(node => {
|
||||||
|
node.addEventListener("click", (e) => {
|
||||||
|
if (busy) return;
|
||||||
|
|
||||||
|
let $icon = node.querySelector(".icon"),
|
||||||
|
$label = node.querySelector(".nonshy-likes"),
|
||||||
|
tableName = node.dataset.tableName,
|
||||||
|
tableID = node.dataset.tableId,
|
||||||
|
liking = false;
|
||||||
|
|
||||||
|
// Toggle the color of the heart.
|
||||||
|
if ($icon.classList.contains(red)) {
|
||||||
|
$icon.classList.remove(red);
|
||||||
|
} else {
|
||||||
|
liking = true;
|
||||||
|
$icon.classList.add(red);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajax request to backend.
|
||||||
|
busy = true;
|
||||||
|
return fetch("/v1/likes", {
|
||||||
|
method: "POST",
|
||||||
|
mode: "same-origin",
|
||||||
|
cache: "no-cache",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
"name": tableName, // TODO
|
||||||
|
"id": parseInt(tableID),
|
||||||
|
"unlike": !liking,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
let likes = data.data.likes;
|
||||||
|
if (likes === 0) {
|
||||||
|
$label.innerHTML = "Like";
|
||||||
|
} else {
|
||||||
|
$label.innerHTML = `Like (${likes})`;
|
||||||
|
}
|
||||||
|
}).catch(resp => {
|
||||||
|
window.alert(resp);
|
||||||
|
}).finally(() => {
|
||||||
|
busy = false;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -136,18 +136,157 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{$Root := .}}
|
||||||
|
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div class="card">
|
<div class="card" id="notifications">
|
||||||
<header class="card-header has-background-warning">
|
<header class="card-header has-background-warning">
|
||||||
<p class="card-header-title">Notifications</p>
|
<p class="card-header-title">Notifications</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
TBD.
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
{{if gt .NavUnreadNotifications 0}}
|
||||||
|
{{.NavUnreadNotifications}} unread notification{{Pluralize64 .NavUnreadNotifications}}.
|
||||||
|
{{else}}
|
||||||
|
No unread notifications.
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<a href="/me?intent=read-notifications" class="button is-link is-light is-small">
|
||||||
|
<span class="icon-text">
|
||||||
|
<span class="icon"><i class="fa fa-check"></i></span>
|
||||||
|
<span>Mark all as read</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table is-striped is-fullwidth is-hoverable">
|
||||||
|
<tbody>
|
||||||
|
{{range .Notifications}}
|
||||||
|
{{$Body := $Root.NotifMap.Get .ID}}
|
||||||
|
<tr>
|
||||||
|
<td class="nonshy-notification-row" data-notification-id="{{.ID}}">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-narrow has-text-centered">
|
||||||
|
{{if not .Read}}
|
||||||
|
<div class="mb-2 nonshy-notification-new">
|
||||||
|
<strong class="tag is-success">NEW!</strong>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<a href="/u/{{.User.Username}}">
|
||||||
|
<figure class="image is-48x48 is-inline-block">
|
||||||
|
{{if .User.ProfilePhoto.ID}}
|
||||||
|
<img src="{{PhotoURL .User.ProfilePhoto.CroppedFilename}}">
|
||||||
|
{{else}}
|
||||||
|
<img src="/static/img/shy.png">
|
||||||
|
{{end}}
|
||||||
|
</figure>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="mb-1">
|
||||||
|
{{if eq .Type "like"}}
|
||||||
|
<span class="icon"><i class="fa fa-heart has-text-danger"></i></span>
|
||||||
|
<span>
|
||||||
|
<a href="/u/{{.User.Username}}"><strong>{{.User.Username}}</strong></a>
|
||||||
|
liked your
|
||||||
|
{{if eq .TableName "photos"}}
|
||||||
|
photo.
|
||||||
|
{{else if eq .TableName "users"}}
|
||||||
|
profile page.
|
||||||
|
{{else}}
|
||||||
|
{{.TableName}}.
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
{{else}}
|
||||||
|
{{.User.Username}} {{.Type}} {{.TableName}} {{.TableID}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo caption? -->
|
||||||
|
{{if $Body.Photo}}
|
||||||
|
<div class="block">
|
||||||
|
<em>{{or $Body.Photo.Caption "No caption."}}</em>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<hr class="has-background-light mb-1">
|
||||||
|
<small title="{{.CreatedAt.Format "2006-01-02 15:04:05"}}">
|
||||||
|
{{SincePrettyCoarse .CreatedAt}} ago
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attached photo? -->
|
||||||
|
{{if $Body.PhotoID}}
|
||||||
|
<div class="column is-one-quarter">
|
||||||
|
<img src="{{PhotoURL $Body.Photo.Filename}}">
|
||||||
|
|
||||||
|
{{if $Body.Photo.Caption}}
|
||||||
|
<small>{{$Body.Photo.Caption}}</small>
|
||||||
|
{{else}}
|
||||||
|
<small><em>No caption.</em></small>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{{if .Pager.HasNext}}
|
||||||
|
<div class="has-text-centered">
|
||||||
|
<a href="{{.Request.URL.Path}}?page={{.Pager.Next}}" class="button">View older notifications</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
// Notifications helper.
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
// Bind to the notification table rows.
|
||||||
|
(document.querySelectorAll(".nonshy-notification-row") || []).forEach(node => {
|
||||||
|
node.addEventListener("click", (e) => {
|
||||||
|
if (busy) return;
|
||||||
|
|
||||||
|
let $newBadge = node.querySelector(".nonshy-notification-new"),
|
||||||
|
ID = node.dataset.notificationId;
|
||||||
|
$newBadge.style.display = "none";
|
||||||
|
|
||||||
|
return fetch("/v1/notifications/read", {
|
||||||
|
method: "POST",
|
||||||
|
mode: "same-origin",
|
||||||
|
cache: "no-cache",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
"id": parseInt(ID),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
console.log(data);
|
||||||
|
}).catch(resp => {
|
||||||
|
window.alert(resp);
|
||||||
|
}).finally(() => {
|
||||||
|
busy = false;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
|
@ -121,16 +121,21 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <div class="column is-narrow has-text-centered">
|
<!-- Like button -->
|
||||||
<button type="button" class="button is-fullwidth">
|
{{$Like := .LikeMap.Get .User.ID}}
|
||||||
<span class="icon-text">
|
<div class="column is-narrow has-text-centered">
|
||||||
<span class="icon">
|
<button type="button" class="button is-fullwidth nonshy-like-button"
|
||||||
<i class="fa fa-thumbs-up"></i>
|
data-table-name="users" data-table-id="{{.User.ID}}"
|
||||||
</span>
|
title="Like this profile">
|
||||||
<span>Like</span>
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div> -->
|
</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">
|
||||||
|
|
|
@ -151,16 +151,4 @@
|
||||||
</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}}
|
{{end}}
|
|
@ -52,10 +52,10 @@
|
||||||
<span>Gallery</span>
|
<span>Gallery</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- <a class="navbar-item" href="/forums">
|
<a class="navbar-item" href="/forum">
|
||||||
<span class="icon"><i class="fa fa-comments"></i></span>
|
<span class="icon"><i class="fa fa-comments"></i></span>
|
||||||
<span>Forums</span>
|
<span>Forum</span>
|
||||||
</a> -->
|
</a>
|
||||||
|
|
||||||
<a class="navbar-item" href="/friends{{if gt .NavFriendRequests 0}}?view=requests{{end}}">
|
<a class="navbar-item" href="/friends{{if gt .NavFriendRequests 0}}?view=requests{{end}}">
|
||||||
<span class="icon"><i class="fa fa-user-group"></i></span>
|
<span class="icon"><i class="fa fa-user-group"></i></span>
|
||||||
|
@ -108,7 +108,7 @@
|
||||||
<div class="navbar-end">
|
<div class="navbar-end">
|
||||||
{{if .LoggedIn }}
|
{{if .LoggedIn }}
|
||||||
<div id="navbar-user" class="navbar-item has-dropdown is-hoverable">
|
<div id="navbar-user" class="navbar-item has-dropdown is-hoverable">
|
||||||
<a class="navbar-link" href="/me">
|
<a class="navbar-link" href="/me{{if .NavUnreadNotifications}}#notifications{{end}}">
|
||||||
<div class="columns is-mobile is-gapless">
|
<div class="columns is-mobile is-gapless">
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<figure class="image is-24x24 mr-2">
|
<figure class="image is-24x24 mr-2">
|
||||||
|
@ -121,13 +121,22 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
{{.CurrentUser.Username}}
|
{{.CurrentUser.Username}}
|
||||||
|
{{if .NavUnreadNotifications}}<span class="tag is-warning ml-1">{{.NavUnreadNotifications}}</span>{{end}}
|
||||||
{{if .NavAdminNotifications}}<span class="tag is-danger ml-1">{{.NavAdminNotifications}}</span>{{end}}
|
{{if .NavAdminNotifications}}<span class="tag is-danger ml-1">{{.NavAdminNotifications}}</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="navbar-dropdown is-right is-hoverable">
|
<div class="navbar-dropdown is-right is-hoverable">
|
||||||
<a class="navbar-item" href="/me">Dashboard</a>
|
<a class="navbar-item" href="/me{{if .NavUnreadNotifications}}#notifications{{end}}">
|
||||||
|
Dashboard
|
||||||
|
{{if .NavUnreadNotifications}}
|
||||||
|
<span class="tag is-warning ml-1">
|
||||||
|
<span class="icon"><i class="fa fa-bell"></i></span>
|
||||||
|
<span>{{.NavUnreadNotifications}}</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
</a>
|
||||||
<a class="navbar-item" href="/u/{{.CurrentUser.Username}}">My Profile</a>
|
<a class="navbar-item" href="/u/{{.CurrentUser.Username}}">My Profile</a>
|
||||||
<a class="navbar-item" href="/photo/u/{{.CurrentUser.Username}}">My Photos</a>
|
<a class="navbar-item" href="/photo/u/{{.CurrentUser.Username}}">My Photos</a>
|
||||||
<a class="navbar-item" href="/photo/upload">Upload Photo</a>
|
<a class="navbar-item" href="/photo/upload">Upload Photo</a>
|
||||||
|
@ -180,6 +189,13 @@
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if gt .NavUnreadNotifications 0}}
|
||||||
|
<a class="tag is-warning" href="/me#notifications">
|
||||||
|
<span class="icon"><i class="fa fa-bell"></i></span>
|
||||||
|
<span>{{.NavUnreadNotifications}}</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if gt .NavAdminNotifications 0}}
|
{{if gt .NavAdminNotifications 0}}
|
||||||
<a class="tag is-danger" href="/admin">
|
<a class="tag is-danger" href="/admin">
|
||||||
<span class="icon"><i class="fa fa-gavel"></i></span>
|
<span class="icon"><i class="fa fa-gavel"></i></span>
|
||||||
|
@ -246,7 +262,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript" src="/static/js/bulma.js"></script>
|
<script type="text/javascript" src="/static/js/bulma.js?build={{.BuildHash}}"></script>
|
||||||
|
<script type="text/javascript" src="/static/js/likes.js?build={{.BuildHash}}"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
143
web/templates/forum/add_edit.html
Normal file
143
web/templates/forum/add_edit.html
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
{{define "title"}}Forums{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="block">
|
||||||
|
<section class="hero is-light is-danger">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">
|
||||||
|
{{if .EditForum}}Edit Forum{{else}}New Forum{{end}}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{$Root := .}}
|
||||||
|
|
||||||
|
<div class="block p-4">
|
||||||
|
<div class="columns is-centered">
|
||||||
|
<div class="column is-two-thirds">
|
||||||
|
<div class="card">
|
||||||
|
<header class="card-header has-background-info">
|
||||||
|
<p class="card-header-title has-text-light">Forum Properties</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<form action="{{.Request.URL.Path}}" method="POST">
|
||||||
|
{{InputCSRF}}
|
||||||
|
<input type="hidden" name="id" value="{{.EditID}}">
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="title">
|
||||||
|
Forum Title
|
||||||
|
</label>
|
||||||
|
<input type="text" class="input"
|
||||||
|
name="title" id="title"
|
||||||
|
placeholder="Forum Title"
|
||||||
|
required
|
||||||
|
{{if .EditForum}}value="{{.EditForum.Title}}"{{end}}>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="fragment">
|
||||||
|
URL Fragment
|
||||||
|
</label>
|
||||||
|
<input type="text" class="input"
|
||||||
|
name="fragment" id="fragment"
|
||||||
|
placeholder="url_fragment"
|
||||||
|
pattern="^[a-z0-9._-]+$"
|
||||||
|
required
|
||||||
|
{{if .EditForum}}value="{{.EditForum.Fragment}}" disabled{{end}}>
|
||||||
|
<p class="help">
|
||||||
|
A unique URL path component for this forum. You can not modify this
|
||||||
|
after the forum is created. Acceptable characters in the range a-z, 0-9 and . - _
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="description">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea class="textarea" cols="80" rows="4"
|
||||||
|
name="description" id="description"
|
||||||
|
placeholder="A short description of the forum.">{{if .EditForum}}{{.EditForum.Description}}{{end}}</textarea>
|
||||||
|
<p class="help">
|
||||||
|
Write a short description of the forum. Markdown formatting
|
||||||
|
is supported here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .CurrentUser.IsAdmin}}
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="category">
|
||||||
|
Category
|
||||||
|
<i class="fa fa-gavel has-text-danger ml-2" title="Admin Only"></i>
|
||||||
|
</label>
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
<select name="category" id="category">
|
||||||
|
{{range .Categories}}
|
||||||
|
<option value="{{.}}"{{if and $Root.EditForum (eq $Root.EditForum.Category .)}} selected{{end}}>
|
||||||
|
{{.}}
|
||||||
|
</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Options</label>
|
||||||
|
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="explicit"
|
||||||
|
value="true"
|
||||||
|
{{if and .EditForum .EditForum.Explicit}}checked{{end}}>
|
||||||
|
Explicit <i class="fa fa-fire has-text-danger ml-1"></i>
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
Check this box if the forum is intended for explicit content. Users must
|
||||||
|
opt-in to see explicit content.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{if .CurrentUser.IsAdmin}}
|
||||||
|
<label class="checkbox mt-3">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="privileged"
|
||||||
|
value="true"
|
||||||
|
{{if and .EditForum .EditForum.Privileged}}checked{{end}}>
|
||||||
|
Privileged <i class="fa fa-gavel has-text-danger ml-1"></i>
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
Check this box if only privileged users are allowed to create new threads
|
||||||
|
in this forum. Privileged users include the forum owner, site admins, and
|
||||||
|
forum moderators.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .CurrentUser.IsAdmin}}
|
||||||
|
<label class="checkbox mt-3">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="permit_photos"
|
||||||
|
value="true"
|
||||||
|
{{if and .EditForum .EditForum.PermitPhotos}}checked{{end}}>
|
||||||
|
Permit Photos <i class="fa fa-camera ml-1"></i>
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
Check this box if the forum allows photos to be uploaded (not implemented)
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<button type="submit" class="button is-success">
|
||||||
|
{{if .EditForum}}Save Forum{{else}}Create Forum{{end}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
120
web/templates/forum/admin.html
Normal file
120
web/templates/forum/admin.html
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
{{define "title"}}Forums{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="block">
|
||||||
|
<section class="hero is-light is-danger">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">
|
||||||
|
<span class="icon mr-4"><i class="fa fa-gavel"></i></span>
|
||||||
|
<span>Forum Administration</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{$Root := .}}
|
||||||
|
|
||||||
|
<div class="block p-2">
|
||||||
|
<a href="/forum/admin/edit" class="button is-success">
|
||||||
|
<span class="icon"><i class="fa fa-plus"></i></span>
|
||||||
|
<span>Create New Forum</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="block p-2">
|
||||||
|
Found <strong>{{.Pager.Total}}</strong> forum{{Pluralize64 .Pager.Total}} you can manage
|
||||||
|
(page {{.Pager.Page}} of {{.Pager.Pages}}).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="block p-2">
|
||||||
|
<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}}?page={{.Pager.Previous}}">Previous</a>
|
||||||
|
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
|
||||||
|
href="{{.Request.URL.Path}}?page={{.Pager.Next}}">Next page</a>
|
||||||
|
<ul class="pagination-list">
|
||||||
|
{{$Root := .}}
|
||||||
|
{{range .Pager.Iter}}
|
||||||
|
<li>
|
||||||
|
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
|
||||||
|
aria-label="Page {{.Page}}"
|
||||||
|
href="{{$Root.Request.URL.Path}}?page={{.Page}}">
|
||||||
|
{{.Page}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block p-2">
|
||||||
|
{{range .Forums}}
|
||||||
|
<div class="box">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<h1 class="title">{{.Title}}</h1>
|
||||||
|
<h2 class="subtitle">
|
||||||
|
/f/{{.Fragment}}
|
||||||
|
{{if .Category}}<span class="ml-4">{{.Category}}</span>{{end}}
|
||||||
|
<span class="ml-4">
|
||||||
|
by <strong><a href="/u/{{.Owner.Username}}">{{.Owner.Username}}</a></strong>
|
||||||
|
{{if .Owner.IsAdmin}}<i class="fa fa-gavel has-text-danger ml-1"></i>{{end}}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{if eq .Description ""}}
|
||||||
|
<p><em>No description</em></p>
|
||||||
|
{{else}}
|
||||||
|
{{ToMarkdown .Description}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{if .Explicit}}
|
||||||
|
<div class="tag is-danger is-light">
|
||||||
|
<span class="icon"><i class="fa fa-fire"></i></span>
|
||||||
|
Explicit
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Privileged}}
|
||||||
|
<div class="tag is-warning is-light">
|
||||||
|
<span class="icon"><i class="fa fa-gavel"></i></span>
|
||||||
|
Privileged
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .PermitPhotos}}
|
||||||
|
<div class="tag is-info is-light">
|
||||||
|
<span class="icon"><i class="fa fa-camera"></i></span>
|
||||||
|
PermitPhotos
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="tag is-light">
|
||||||
|
Created {{.CreatedAt.Format "2006-01-02 15:04:05"}}
|
||||||
|
</div>
|
||||||
|
<div class="tag is-light">
|
||||||
|
Updated {{.UpdatedAt.Format "2006-01-02 15:04:05"}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<a href="/forum/admin/edit?id={{.ID}}" class="button has-text-success">
|
||||||
|
<span class="icon"><i class="fa fa-edit"></i></span>
|
||||||
|
<span>Edit</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
160
web/templates/forum/board_index.html
Normal file
160
web/templates/forum/board_index.html
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
{{define "title"}}{{.Forum.Title}}{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="block">
|
||||||
|
<section class="hero is-light is-success">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">
|
||||||
|
<a href="/f/{{.Forum.Fragment}}" class="has-text-light">
|
||||||
|
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
|
||||||
|
<span>{{.Forum.Title}}</span>
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{$Root := .}}
|
||||||
|
|
||||||
|
<div class="block px-4">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/forum">Forums</a></li>
|
||||||
|
<li class="is-active">
|
||||||
|
<a href="{{.Request.URL.Path}}" aria-current="page">{{.Forum.Title}}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
{{if or .CurrentUser.IsAdmin (not .Forum.Privileged) (eq .Forum.OwnerID .CurrentUser.ID)}}
|
||||||
|
<a href="/forum/post?to={{.Forum.Fragment}}" class="button is-primary">
|
||||||
|
<span class="icon"><i class="fa fa-plus"></i></span>
|
||||||
|
<span>New Thread</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="block p-2">
|
||||||
|
Found <strong>{{.Pager.Total}}</strong> post{{Pluralize64 .Pager.Total}} on this forum
|
||||||
|
(page {{.Pager.Page}} of {{.Pager.Pages}}).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{if .Forum.Privileged}}
|
||||||
|
<div class="block p-2 notification is-warning is-light">
|
||||||
|
<i class="fa fa-gavel mr-1"></i>
|
||||||
|
Only moderators may create new threads on this forum. You may be able to reply to threads here.
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="block p-2">
|
||||||
|
<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}}?page={{.Pager.Previous}}">Previous</a>
|
||||||
|
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
|
||||||
|
href="{{.Request.URL.Path}}?page={{.Pager.Next}}">Next page</a>
|
||||||
|
<ul class="pagination-list">
|
||||||
|
{{$Root := .}}
|
||||||
|
{{range .Pager.Iter}}
|
||||||
|
<li>
|
||||||
|
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
|
||||||
|
aria-label="Page {{.Page}}"
|
||||||
|
href="{{$Root.Request.URL.Path}}?page={{.Page}}">
|
||||||
|
{{.Page}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{$Root := .}}
|
||||||
|
<div class="block p-2">
|
||||||
|
{{range .Threads}}
|
||||||
|
{{$Stats := $Root.ThreadMap.Get .ID}}
|
||||||
|
<div class="box has-background-link-light">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-2 has-text-centered">
|
||||||
|
<div>
|
||||||
|
<a href="/u/{{.Comment.User.Username}}">
|
||||||
|
<figure class="image is-96x96 is-inline-block">
|
||||||
|
{{if .Comment.User.ProfilePhoto.ID}}
|
||||||
|
<img src="{{PhotoURL .Comment.User.ProfilePhoto.CroppedFilename}}">
|
||||||
|
{{else}}
|
||||||
|
<img src="/static/img/shy.png">
|
||||||
|
{{end}}
|
||||||
|
</figure>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<a href="/u/{{.Comment.User.Username}}">{{.Comment.User.Username}}</a>
|
||||||
|
</div>
|
||||||
|
<div class="column content">
|
||||||
|
<a href="/forum/thread/{{.ID}}" class="has-text-dark">
|
||||||
|
<h1 class="title pt-0">
|
||||||
|
{{if .Pinned}}<sup class="fa fa-thumbtack has-text-success mr-2 is-size-5" title="Pinned"></sup>{{end}}
|
||||||
|
{{or .Title "Untitled"}}
|
||||||
|
</h1>
|
||||||
|
{{TrimEllipses .Comment.Message 256}}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<hr class="mb-1">
|
||||||
|
<div>
|
||||||
|
{{if .Pinned}}
|
||||||
|
<span class="tag is-success is-light mr-2">
|
||||||
|
<span class="icon"><i class="fa fa-thumbtack"></i></span>
|
||||||
|
<span>Pinned</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Explicit}}
|
||||||
|
<span class="tag is-danger is-light mr-2">
|
||||||
|
<span class="icon"><i class="fa fa-fire"></i></span>
|
||||||
|
<span>NSFW</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .NoReply}}
|
||||||
|
<span class="tag is-warning is-light mr-2" title="This thread can not be replied to.">
|
||||||
|
<span class="icon"><i class="fa fa-ban"></i></span>
|
||||||
|
<span>No Reply</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<em title="{{.UpdatedAt.Format "2006-01-02 15:04:05"}}">Updated {{SincePrettyCoarse .UpdatedAt}} ago</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<div class="columns is-mobile">
|
||||||
|
<div class="column has-text-centered">
|
||||||
|
<div class="box">
|
||||||
|
<p class="is-size-7">Replies</p>
|
||||||
|
{{if $Stats}}
|
||||||
|
{{$Stats.Replies}}
|
||||||
|
{{else}}
|
||||||
|
err
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column has-text-centered">
|
||||||
|
<div class="box">
|
||||||
|
<p class="is-size-7">Views</p>
|
||||||
|
{{if $Stats}}
|
||||||
|
{{$Stats.Views}}
|
||||||
|
{{else}}
|
||||||
|
err
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{end}}
|
141
web/templates/forum/index.html
Normal file
141
web/templates/forum/index.html
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
{{define "title"}}Forums{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="block">
|
||||||
|
<section class="hero is-light is-success">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">
|
||||||
|
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
|
||||||
|
<span>Forums</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block p-2">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">To Do</div>
|
||||||
|
{{if .CurrentUser.IsAdmin}}
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<a href="/forum/admin" class="button is-small has-text-danger">
|
||||||
|
<span class="icon"><i class="fa fa-gavel"></i></span>
|
||||||
|
<span>Manage Forums</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{$Root := .}}
|
||||||
|
{{range .Categories}}
|
||||||
|
<div class="block p-2">
|
||||||
|
<h1 class="title">{{.Category}}</h1>
|
||||||
|
|
||||||
|
{{if eq (len .Forums) 0}}
|
||||||
|
<em>
|
||||||
|
There are no forums under this category.
|
||||||
|
{{if not $Root.CurrentUser.Explicit}}Your content filters (non-explicit) may be hiding some forums.{{end}}
|
||||||
|
</em>
|
||||||
|
{{else}}
|
||||||
|
{{range .Forums}}
|
||||||
|
{{$Stats := $Root.ForumMap.Get .ID}}
|
||||||
|
<div class="card block has-background-primary-light">
|
||||||
|
<!-- <header class="card-header has-background-success">
|
||||||
|
<p class="card-header-title has-text-light">
|
||||||
|
{{.Title}}
|
||||||
|
</p>
|
||||||
|
</header> -->
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
|
||||||
|
<h1 class="title">
|
||||||
|
<a href="/f/{{.Fragment}}">{{.Title}}</a>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
{{if .Description}}
|
||||||
|
{{ToMarkdown .Description}}
|
||||||
|
{{else}}
|
||||||
|
<em>No description</em>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{if .Explicit}}
|
||||||
|
<span class="tag is-danger is-light">
|
||||||
|
<span class="icon"><i class="fa fa-fire"></i></span>
|
||||||
|
<span>Explicit</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Privileged}}
|
||||||
|
<span class="tag is-warning is-light">
|
||||||
|
<span class="icon"><i class="fa fa-gavel"></i></span>
|
||||||
|
<span>Privileged</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="box has-background-success-light">
|
||||||
|
<h2 class="subtitle mb-0">Latest Post</h2>
|
||||||
|
{{if $Stats.RecentThread}}
|
||||||
|
<a href="/forum/thread/{{$Stats.RecentThread.ID}}">
|
||||||
|
<strong>{{$Stats.RecentThread.Title}}</strong>
|
||||||
|
</a>
|
||||||
|
<em>by {{$Stats.RecentThread.Comment.User.Username}}</em>
|
||||||
|
<div>
|
||||||
|
<small title="{{$Stats.RecentThread.UpdatedAt.Format "2006-01-02 15:04:05"}}">Last updated {{SincePrettyCoarse $Stats.RecentThread.UpdatedAt}} ago</small>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<em>No posts found.</em>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-3">
|
||||||
|
<div class="columns is-mobile">
|
||||||
|
<div class="column has-text-centered">
|
||||||
|
<div class="box has-background-warning-light">
|
||||||
|
<p class="is-size-7">Topics</p>
|
||||||
|
{{if $Stats}}
|
||||||
|
{{$Stats.Threads}}
|
||||||
|
{{else}}
|
||||||
|
err
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column has-text-centered">
|
||||||
|
<div class="box has-background-warning-light">
|
||||||
|
<p class="is-size-7">Posts</p>
|
||||||
|
{{if $Stats}}
|
||||||
|
{{$Stats.Posts}}
|
||||||
|
{{else}}
|
||||||
|
err
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column has-text-centered">
|
||||||
|
<div class="box has-background-warning-light">
|
||||||
|
<p class="is-size-7">Users</p>
|
||||||
|
{{if $Stats}}
|
||||||
|
{{$Stats.Users}}
|
||||||
|
{{else}}
|
||||||
|
err
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}<!-- range .Categories -->
|
||||||
|
|
||||||
|
{{end}}
|
160
web/templates/forum/new_post.html
Normal file
160
web/templates/forum/new_post.html
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
{{define "title"}}
|
||||||
|
{{if .EditCommentID}}
|
||||||
|
Edit Comment
|
||||||
|
{{else if .Thread}}
|
||||||
|
Reply to Thread
|
||||||
|
{{else}}
|
||||||
|
New Forum Post
|
||||||
|
{{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 on: {{or .Thread.Title "Untitled Thread"}}
|
||||||
|
{{else if .Thread}}
|
||||||
|
Reply to: {{or .Thread.Title "Untitled Thread"}}
|
||||||
|
{{else}}
|
||||||
|
Post to: {{.Forum.Title}}
|
||||||
|
{{end}}
|
||||||
|
</h1>
|
||||||
|
<h2 class="subtitle">
|
||||||
|
/f/{{.Forum.Fragment}}
|
||||||
|
</h2>
|
||||||
|
</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 if .Thread}}
|
||||||
|
Reply to Thread
|
||||||
|
{{else}}
|
||||||
|
New Thread
|
||||||
|
{{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="/forum/post?to={{.Forum.Fragment}}{{if .Thread}}&thread={{.Thread.ID}}{{end}}{{if .EditCommentID}}&edit={{.EditCommentID}}{{end}}" method="POST">
|
||||||
|
{{InputCSRF}}
|
||||||
|
|
||||||
|
{{if not .Thread}}
|
||||||
|
<div class="field block">
|
||||||
|
<label for="title" class="label">Title</label>
|
||||||
|
<input type="text" class="input"
|
||||||
|
name="title" id="title"
|
||||||
|
placeholder="A title for your post"
|
||||||
|
value="{{.PostTitle}}"
|
||||||
|
required>
|
||||||
|
<p class="help">Required.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{{if or (not .Thread) .EditThreadSettings}}
|
||||||
|
<div class="field block">
|
||||||
|
{{if or .CurrentUser.IsAdmin (and .Forum (eq .Forum.OwnerID .CurrentUser.ID))}}
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="pinned"
|
||||||
|
value="true"
|
||||||
|
{{if .IsPinned}}checked{{end}}>
|
||||||
|
Pinned to top
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if or .CurrentUser.Explicit .IsExplicit}}
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="explicit"
|
||||||
|
value="true"
|
||||||
|
{{if .IsExplicit}}checked{{end}}>
|
||||||
|
Mark as Explicit (NSFW)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .CurrentUser.IsAdmin}}
|
||||||
|
<div>
|
||||||
|
<label class="checkbox has-text-danger">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="noreply"
|
||||||
|
value="true"
|
||||||
|
{{if .IsNoReply}}checked{{end}}>
|
||||||
|
No replies allowed <i class="fa fa-gavel ml-1"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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}}
|
205
web/templates/forum/thread.html
Normal file
205
web/templates/forum/thread.html
Normal file
|
@ -0,0 +1,205 @@
|
||||||
|
{{define "title"}}{{.Forum.Title}}{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="block">
|
||||||
|
<section class="hero is-light is-success">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">
|
||||||
|
<a href="/f/{{.Forum.Fragment}}" class="has-text-light">
|
||||||
|
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
|
||||||
|
<span>{{.Forum.Title}}</span>
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{$Root := .}}
|
||||||
|
|
||||||
|
<div class="block px-4">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/forum">Forums</a></li>
|
||||||
|
<li><a href="/f/{{.Forum.Fragment}}">{{.Forum.Title}}</a></Li>
|
||||||
|
<li class="is-active">
|
||||||
|
<a href="{{.Request.URL.Path}}" aria-current="page">{{or .Thread.Title "Untitled Thread"}}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
{{if not .Thread.NoReply}}
|
||||||
|
<a href="/forum/post?to={{.Forum.Fragment}}&thread={{.Thread.ID}}" class="button is-link">
|
||||||
|
<span class="icon"><i class="fa fa-reply"></i></span>
|
||||||
|
<span>Add Reply</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="title px-4">
|
||||||
|
{{if .Thread.Pinned}}<sup class="fa fa-thumbtack has-text-success mr-2 is-size-5" title="Pinned"></sup>{{end}}
|
||||||
|
{{or .Thread.Title "Untitled Thread"}}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="px-4">
|
||||||
|
{{if .Thread.Pinned}}
|
||||||
|
<span class="tag is-success is-light mr-2">
|
||||||
|
<span class="icon"><i class="fa fa-thumbtack"></i></span>
|
||||||
|
<span>Pinned</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Thread.Explicit}}
|
||||||
|
<span class="tag is-danger is-light mr-2">
|
||||||
|
<span class="icon"><i class="fa fa-fire"></i></span>
|
||||||
|
<span>NSFW</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Thread.NoReply}}
|
||||||
|
<span class="tag is-warning is-light mr-2" title="This thread can not be replied to.">
|
||||||
|
<span class="icon"><i class="fa fa-ban"></i></span>
|
||||||
|
<span>No Reply</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<em title="{{.Thread.UpdatedAt.Format "2006-01-02 15:04:05"}}">Updated {{SincePrettyCoarse .Thread.UpdatedAt}} ago</em>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="block p-4">
|
||||||
|
Found <strong>{{.Pager.Total}}</strong> post{{Pluralize64 .Pager.Total}} on this thread
|
||||||
|
(page {{.Pager.Page}} of {{.Pager.Pages}}).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="block p-2">
|
||||||
|
<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}}?page={{.Pager.Previous}}">Previous</a>
|
||||||
|
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
|
||||||
|
href="{{.Request.URL.Path}}?page={{.Pager.Next}}">Next page</a>
|
||||||
|
<ul class="pagination-list">
|
||||||
|
{{$Root := .}}
|
||||||
|
{{range .Pager.Iter}}
|
||||||
|
<li>
|
||||||
|
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
|
||||||
|
aria-label="Page {{.Page}}"
|
||||||
|
href="{{$Root.Request.URL.Path}}?page={{.Page}}">
|
||||||
|
{{.Page}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{$Root := .}}
|
||||||
|
<div class="block p-2">
|
||||||
|
{{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>
|
||||||
|
{{if .User.IsAdmin}}
|
||||||
|
<div class="is-size-7 mt-1">
|
||||||
|
<span class="tag is-danger">
|
||||||
|
<span class="icon"><i class="fa fa-gavel"></i></span>
|
||||||
|
<span>Admin</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="column content">
|
||||||
|
{{ToMarkdown .Message}}
|
||||||
|
|
||||||
|
<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 not $Root.Thread.NoReply}}
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}"e={{.ID}}" class="has-text-dark">
|
||||||
|
<span class="icon"><i class="fa fa-quote-right"></i></span>
|
||||||
|
<span>Quote</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}" class="has-text-dark">
|
||||||
|
<span class="icon"><i class="fa fa-reply"></i></span>
|
||||||
|
<span>Reply</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if or $Root.CurrentUser.IsAdmin (eq $Root.CurrentUser.ID .User.ID)}}
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}&edit={{.ID}}" class="has-text-dark">
|
||||||
|
<span class="icon"><i class="fa fa-edit"></i></span>
|
||||||
|
<span>Edit</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}&edit={{.ID}}&delete=true" 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>
|
||||||
|
|
||||||
|
{{if $Root.CurrentUser.IsAdmin}}
|
||||||
|
<div>
|
||||||
|
<span class="tag is-primary is-light">
|
||||||
|
<span class="icon"><i class="fa fa-database"></i></span>
|
||||||
|
<span>ID: {{.ID}}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Thread.NoReply}}
|
||||||
|
<div class="block notification is-warning is-light">
|
||||||
|
<i class="fa fa-ban pr-2"></i>
|
||||||
|
This thread is not accepting any new replies.
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="block p-2 has-text-right">
|
||||||
|
<a href="/forum/post?to={{.Forum.Fragment}}&thread={{.Thread.ID}}" class="button is-link">
|
||||||
|
<span class="icon"><i class="fa fa-reply"></i></span>
|
||||||
|
<span>Add Reply</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{end}}
|
2
web/templates/partials/like_button.html
Normal file
2
web/templates/partials/like_button.html
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
{{define "like-button"}}
|
||||||
|
{{end}}
|
|
@ -259,6 +259,22 @@
|
||||||
{{else}}<em>No caption</em>{{end}}
|
{{else}}<em>No caption</em>{{end}}
|
||||||
|
|
||||||
{{template "card-body" .}}
|
{{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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="card-footer">
|
<footer class="card-footer">
|
||||||
|
@ -340,6 +356,22 @@
|
||||||
{{else}}<em>No caption</em>{{end}}
|
{{else}}<em>No caption</em>{{end}}
|
||||||
|
|
||||||
{{template "card-body" .}}
|
{{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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="card-footer">
|
<footer class="card-footer">
|
||||||
|
|
Loading…
Reference in New Issue
Block a user