User Forums: Newest Tab, Moderators
* The "Newest" tab of the forum is updated with new filter options. * Which forums: All, Official, Community, My List * Show: By threads, All posts * The option for "Which forums" is saved in the user's preferences and set as their default on future visits, similar to the Site Gallery "Whose photos" option. * So users can subscribe to their favorite forums and always get their latest posts easily while filtering out the rest. * Forum Moderators * Add the ability to add and remove moderators for your forum. * Users are notified when they are added as a moderator. * Moderators can opt themselves out by unfollowing the forum. * ForumMembership: add unique constraint on user_id,forum_id.
This commit is contained in:
parent
eb85b2e090
commit
ed4a9f8c89
|
@ -45,7 +45,7 @@ func AddEdit() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
// Do we have permission?
|
// Do we have permission?
|
||||||
if found.OwnerID != currentUser.ID && !currentUser.IsAdmin {
|
if !found.CanEdit(currentUser) {
|
||||||
templates.ForbiddenPage(w, r)
|
templates.ForbiddenPage(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -67,8 +67,8 @@ func AddEdit() http.HandlerFunc {
|
||||||
isPrivate = r.PostFormValue("private") == "true"
|
isPrivate = r.PostFormValue("private") == "true"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sanity check admin-only settings.
|
// Sanity check admin-only settings -> default these to OFF.
|
||||||
if !currentUser.IsAdmin {
|
if !currentUser.HasAdminScope(config.ScopeForumAdmin) {
|
||||||
isPrivileged = false
|
isPrivileged = false
|
||||||
isPermitPhotos = false
|
isPermitPhotos = false
|
||||||
isPrivate = false
|
isPrivate = false
|
||||||
|
@ -81,18 +81,25 @@ func AddEdit() http.HandlerFunc {
|
||||||
models.NewFieldDiff("Description", forum.Description, description),
|
models.NewFieldDiff("Description", forum.Description, description),
|
||||||
models.NewFieldDiff("Category", forum.Category, category),
|
models.NewFieldDiff("Category", forum.Category, category),
|
||||||
models.NewFieldDiff("Explicit", forum.Explicit, isExplicit),
|
models.NewFieldDiff("Explicit", forum.Explicit, isExplicit),
|
||||||
models.NewFieldDiff("Privileged", forum.Privileged, isPrivileged),
|
|
||||||
models.NewFieldDiff("PermitPhotos", forum.PermitPhotos, isPermitPhotos),
|
|
||||||
models.NewFieldDiff("Private", forum.Private, isPrivate),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
forum.Title = title
|
forum.Title = title
|
||||||
forum.Description = description
|
forum.Description = description
|
||||||
forum.Category = category
|
forum.Category = category
|
||||||
forum.Explicit = isExplicit
|
forum.Explicit = isExplicit
|
||||||
forum.Privileged = isPrivileged
|
|
||||||
forum.PermitPhotos = isPermitPhotos
|
// Forum Admin-only options: if the current viewer is not a forum admin, do not change these settings.
|
||||||
forum.Private = isPrivate
|
// e.g.: the front-end checkboxes are hidden and don't want to accidentally unset these!
|
||||||
|
if currentUser.HasAdminScope(config.ScopeForumAdmin) {
|
||||||
|
diffs = append(diffs,
|
||||||
|
models.NewFieldDiff("Privileged", forum.Privileged, isPrivileged),
|
||||||
|
models.NewFieldDiff("PermitPhotos", forum.PermitPhotos, isPermitPhotos),
|
||||||
|
models.NewFieldDiff("Private", forum.Private, isPrivate),
|
||||||
|
)
|
||||||
|
forum.Privileged = isPrivileged
|
||||||
|
forum.PermitPhotos = isPermitPhotos
|
||||||
|
forum.Private = isPrivate
|
||||||
|
}
|
||||||
|
|
||||||
// Save it.
|
// Save it.
|
||||||
if err := forum.Save(); err == nil {
|
if err := forum.Save(); err == nil {
|
||||||
|
@ -164,12 +171,20 @@ func AddEdit() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = editID
|
// Get the list of moderators.
|
||||||
|
var mods []*models.User
|
||||||
|
if forum != nil {
|
||||||
|
mods, err = forum.GetModerators()
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Error getting moderators list: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"EditID": editID,
|
"EditID": editID,
|
||||||
"EditForum": forum,
|
"EditForum": forum,
|
||||||
"Categories": config.ForumCategories,
|
"Categories": config.ForumCategories,
|
||||||
|
"Moderators": mods,
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
|
@ -9,8 +9,8 @@ import (
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Browse all existing forums.
|
// Explore all existing forums.
|
||||||
func Browse() http.HandlerFunc {
|
func Explore() http.HandlerFunc {
|
||||||
// This page shares a template with the board index (Categories) page.
|
// This page shares a template with the board index (Categories) page.
|
||||||
tmpl := templates.Must("forum/index.html")
|
tmpl := templates.Must("forum/index.html")
|
||||||
|
|
||||||
|
@ -84,8 +84,8 @@ func Browse() http.HandlerFunc {
|
||||||
followMap := models.MapForumMemberships(currentUser, forums)
|
followMap := models.MapForumMemberships(currentUser, forums)
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"CurrentForumTab": "browse",
|
"CurrentForumTab": "explore",
|
||||||
"IsBrowseTab": true,
|
"IsExploreTab": true,
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
"Categories": categorized,
|
"Categories": categorized,
|
||||||
"ForumMap": forumMap,
|
"ForumMap": forumMap,
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
@ -71,8 +72,15 @@ func Forum() http.HandlerFunc {
|
||||||
// Map the statistics (replies, views) of these threads.
|
// Map the statistics (replies, views) of these threads.
|
||||||
threadMap := models.MapThreadStatistics(threads)
|
threadMap := models.MapThreadStatistics(threads)
|
||||||
|
|
||||||
|
// Load the forum's moderators.
|
||||||
|
mods, err := forum.GetModerators()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Getting forum moderators: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"Forum": forum,
|
"Forum": forum,
|
||||||
|
"ForumModerators": mods,
|
||||||
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
|
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
|
||||||
"Threads": threads,
|
"Threads": threads,
|
||||||
"ThreadMap": threadMap,
|
"ThreadMap": threadMap,
|
||||||
|
|
140
pkg/controller/forum/moderators.go
Normal file
140
pkg/controller/forum/moderators.go
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
package forum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ManageModerators controller (/forum/admin/moderators) to appoint moderators to your (user) forum.
|
||||||
|
func ManageModerators() http.HandlerFunc {
|
||||||
|
// Reuse the upload page but with an EditPhoto variable.
|
||||||
|
tmpl := templates.Must("forum/moderators.html")
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
intent = r.FormValue("intent")
|
||||||
|
stringID = r.FormValue("forum_id")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse forum_id query parameter.
|
||||||
|
var forumID uint64
|
||||||
|
if stringID != "" {
|
||||||
|
if i, err := strconv.Atoi(stringID); err == nil {
|
||||||
|
forumID = uint64(i)
|
||||||
|
} else {
|
||||||
|
session.FlashError(w, r, "Edit parameter: forum_id was not an integer")
|
||||||
|
templates.Redirect(w, "/forum/admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect URLs
|
||||||
|
var (
|
||||||
|
next = fmt.Sprintf("%s?forum_id=%d", r.URL.Path, forumID)
|
||||||
|
nextFinished = fmt.Sprintf("/forum/admin/edit?id=%d", forumID)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load the current user.
|
||||||
|
currentUser, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Unexpected error: could not get currentUser.")
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Are we adding/removing a user as moderator?
|
||||||
|
var (
|
||||||
|
username = r.FormValue("to")
|
||||||
|
user *models.User
|
||||||
|
)
|
||||||
|
if username != "" {
|
||||||
|
if found, err := models.FindUser(username); err != nil {
|
||||||
|
templates.NotFoundPage(w, r)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
user = found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the forum by its fragment.
|
||||||
|
forum, err := models.GetForum(forumID)
|
||||||
|
if err != nil {
|
||||||
|
templates.NotFoundPage(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// User must be the owner of this forum, or a privileged admin.
|
||||||
|
if !forum.CanEdit(currentUser) {
|
||||||
|
templates.ForbiddenPage(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The forum owner can not add themself.
|
||||||
|
if user != nil && forum.OwnerID == user.ID {
|
||||||
|
session.FlashError(w, r, "You can not add the forum owner to its moderators list.")
|
||||||
|
templates.Redirect(w, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// POSTing?
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
switch intent {
|
||||||
|
case "submit":
|
||||||
|
// Confirmed adding a moderator.
|
||||||
|
if _, err := forum.AddModerator(user); err != nil {
|
||||||
|
session.FlashError(w, r, "Error adding the moderator: %s", err)
|
||||||
|
templates.Redirect(w, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a notification for this.
|
||||||
|
notif := &models.Notification{
|
||||||
|
UserID: user.ID,
|
||||||
|
AboutUser: *currentUser,
|
||||||
|
Type: models.NotificationForumModerator,
|
||||||
|
TableName: "forums",
|
||||||
|
TableID: forum.ID,
|
||||||
|
Link: fmt.Sprintf("/f/%s", forum.Fragment),
|
||||||
|
}
|
||||||
|
if err := models.CreateNotification(notif); err != nil {
|
||||||
|
log.Error("Couldn't create PrivatePhoto notification: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Flash(w, r, "%s has been added to the moderators list!", user.Username)
|
||||||
|
templates.Redirect(w, nextFinished)
|
||||||
|
return
|
||||||
|
case "confirm-remove":
|
||||||
|
// Confirm removing a moderator.
|
||||||
|
if _, err := forum.RemoveModerator(user); err != nil {
|
||||||
|
session.FlashError(w, r, "Error removing the moderator: %s", err)
|
||||||
|
templates.Redirect(w, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke any past notifications they had about being added as moderator.
|
||||||
|
if err := models.RemoveSpecificNotification(user.ID, models.NotificationForumModerator, "forums", forum.ID); err != nil {
|
||||||
|
log.Error("Couldn't revoke the forum moderator notification: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Flash(w, r, "%s has been removed from the moderators list.", user.Username)
|
||||||
|
templates.Redirect(w, nextFinished)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var vars = map[string]interface{}{
|
||||||
|
"Forum": forum,
|
||||||
|
"User": user,
|
||||||
|
"IsRemoving": intent == "remove",
|
||||||
|
}
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -17,6 +17,9 @@ func Newest() http.HandlerFunc {
|
||||||
// Query parameters.
|
// Query parameters.
|
||||||
var (
|
var (
|
||||||
allComments = r.FormValue("all") == "true"
|
allComments = r.FormValue("all") == "true"
|
||||||
|
whichForums = r.FormValue("which")
|
||||||
|
categories = []string{}
|
||||||
|
subscribed bool
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get the current user.
|
// Get the current user.
|
||||||
|
@ -27,6 +30,29 @@ func Newest() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recall the user's default "Which forum:" answer if not selected.
|
||||||
|
if whichForums == "" {
|
||||||
|
whichForums = currentUser.GetProfileField("forum_newest_default")
|
||||||
|
if whichForums == "" {
|
||||||
|
whichForums = "official"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Narrow down to which set of forums?
|
||||||
|
switch whichForums {
|
||||||
|
case "official":
|
||||||
|
categories = config.ForumCategories
|
||||||
|
case "community":
|
||||||
|
categories = []string{""}
|
||||||
|
case "followed":
|
||||||
|
subscribed = true
|
||||||
|
default:
|
||||||
|
whichForums = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store their "Which forums" filter to be their new default view.
|
||||||
|
currentUser.SetProfileField("forum_newest_default", whichForums)
|
||||||
|
|
||||||
// Get all the categorized index forums.
|
// Get all the categorized index forums.
|
||||||
var pager = &models.Pagination{
|
var pager = &models.Pagination{
|
||||||
Page: 1,
|
Page: 1,
|
||||||
|
@ -34,7 +60,7 @@ func Newest() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
pager.ParsePage(r)
|
pager.ParsePage(r)
|
||||||
|
|
||||||
posts, err := models.PaginateRecentPosts(currentUser, config.ForumCategories, allComments, pager)
|
posts, err := models.PaginateRecentPosts(currentUser, categories, subscribed, allComments, pager)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
|
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
|
||||||
templates.Redirect(w, "/")
|
templates.Redirect(w, "/")
|
||||||
|
@ -56,7 +82,10 @@ func Newest() http.HandlerFunc {
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
"RecentPosts": posts,
|
"RecentPosts": posts,
|
||||||
"PhotoMap": photos,
|
"PhotoMap": photos,
|
||||||
"AllComments": allComments,
|
|
||||||
|
// Filter options.
|
||||||
|
"WhichForums": whichForums,
|
||||||
|
"AllComments": allComments,
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
|
@ -3,6 +3,7 @@ package forum
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
@ -51,6 +52,13 @@ func Subscribe() http.HandlerFunc {
|
||||||
case "unfollow":
|
case "unfollow":
|
||||||
fm, err := models.GetForumMembership(currentUser, forum)
|
fm, err := models.GetForumMembership(currentUser, forum)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
// Were we a moderator previously? If so, revoke the notification about it.
|
||||||
|
if fm.IsModerator {
|
||||||
|
if err := models.RemoveSpecificNotification(currentUser.ID, models.NotificationForumModerator, "forums", forum.ID); err != nil {
|
||||||
|
log.Error("User unsubscribed from forum and couldn't remove their moderator notification: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err = fm.Delete()
|
err = fm.Delete()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't delete your forum membership: %s", err)
|
session.FlashError(w, r, "Couldn't delete your forum membership: %s", err)
|
||||||
|
|
|
@ -101,14 +101,15 @@ func Thread() http.HandlerFunc {
|
||||||
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
|
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"Forum": forum,
|
"Forum": forum,
|
||||||
"Thread": thread,
|
"Thread": thread,
|
||||||
"Comments": comments,
|
"Comments": comments,
|
||||||
"LikeMap": commentLikeMap,
|
"LikeMap": commentLikeMap,
|
||||||
"PhotoMap": photos,
|
"PhotoMap": photos,
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
"CanModerate": canModerate,
|
"CanModerate": canModerate,
|
||||||
"IsSubscribed": isSubscribed,
|
"IsSubscribed": isSubscribed,
|
||||||
|
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -27,7 +28,7 @@ type Forum struct {
|
||||||
|
|
||||||
// Preload related tables for the forum (classmethod).
|
// Preload related tables for the forum (classmethod).
|
||||||
func (f *Forum) Preload() *gorm.DB {
|
func (f *Forum) Preload() *gorm.DB {
|
||||||
return DB.Preload("Owner")
|
return DB.Preload("Owner").Preload("Owner.ProfilePhoto")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetForum by ID.
|
// GetForum by ID.
|
||||||
|
@ -69,6 +70,13 @@ func ForumByFragment(fragment string) (*Forum, error) {
|
||||||
return f, result.Error
|
return f, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanEdit checks if the user has edit rights over this forum.
|
||||||
|
//
|
||||||
|
// That is, they are its Owner or they are an admin with Manage Forums permission.
|
||||||
|
func (f *Forum) CanEdit(user *User) bool {
|
||||||
|
return user.HasAdminScope(config.ScopeForumAdmin) || f.OwnerID == user.ID
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
PaginateForums scans over the available forums for a user.
|
PaginateForums scans over the available forums for a user.
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -9,11 +10,13 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// ForumMembership table.
|
// ForumMembership table.
|
||||||
|
//
|
||||||
|
// Unique key constraint pairs user_id and forum_id.
|
||||||
type ForumMembership struct {
|
type ForumMembership struct {
|
||||||
ID uint64 `gorm:"primaryKey"`
|
ID uint64 `gorm:"primaryKey"`
|
||||||
UserID uint64 `gorm:"index"`
|
UserID uint64 `gorm:"uniqueIndex:idx_forum_membership"`
|
||||||
User User `gorm:"foreignKey:user_id"`
|
User User `gorm:"foreignKey:user_id"`
|
||||||
ForumID uint64 `gorm:"index"`
|
ForumID uint64 `gorm:"uniqueIndex:idx_forum_membership"`
|
||||||
Forum Forum `gorm:"foreignKey:forum_id"`
|
Forum Forum `gorm:"foreignKey:forum_id"`
|
||||||
Approved bool `gorm:"index"`
|
Approved bool `gorm:"index"`
|
||||||
IsModerator bool `gorm:"index"`
|
IsModerator bool `gorm:"index"`
|
||||||
|
@ -39,7 +42,7 @@ func CreateForumMembership(user *User, forum *Forum) (*ForumMembership, error) {
|
||||||
return f, result.Error
|
return f, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetForumMembership looks up a forum membership.
|
// GetForumMembership looks up a forum membership, returning an error if one is not found.
|
||||||
func GetForumMembership(user *User, forum *Forum) (*ForumMembership, error) {
|
func GetForumMembership(user *User, forum *Forum) (*ForumMembership, error) {
|
||||||
var (
|
var (
|
||||||
f = &ForumMembership{}
|
f = &ForumMembership{}
|
||||||
|
@ -51,12 +54,88 @@ func GetForumMembership(user *User, forum *Forum) (*ForumMembership, error) {
|
||||||
return f, result.Error
|
return f, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddModerator appoints a moderator to the forum, returning that user's ForumMembership.
|
||||||
|
//
|
||||||
|
// If the target is not following the forum, a ForumMembership is created, marked as a moderator and returned.
|
||||||
|
func (f *Forum) AddModerator(user *User) (*ForumMembership, error) {
|
||||||
|
var fm *ForumMembership
|
||||||
|
if found, err := GetForumMembership(user, f); err != nil {
|
||||||
|
fm = &ForumMembership{
|
||||||
|
User: *user,
|
||||||
|
Forum: *f,
|
||||||
|
Approved: true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fm = found
|
||||||
|
}
|
||||||
|
|
||||||
|
// They are already a moderator?
|
||||||
|
if fm.IsModerator {
|
||||||
|
return fm, errors.New("they are already a moderator of this forum")
|
||||||
|
}
|
||||||
|
|
||||||
|
fm.IsModerator = true
|
||||||
|
err := fm.Save()
|
||||||
|
return fm, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveModerator will unset a user's moderator flag on this forum.
|
||||||
|
func (f *Forum) RemoveModerator(user *User) (*ForumMembership, error) {
|
||||||
|
fm, err := GetForumMembership(user, f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fm.IsModerator = false
|
||||||
|
err = fm.Save()
|
||||||
|
return fm, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModerators loads all of the moderators of a forum, ordered alphabetically by username.
|
||||||
|
func (f *Forum) GetModerators() ([]*User, error) {
|
||||||
|
// Find all forum memberships that moderate us.
|
||||||
|
var (
|
||||||
|
fm = []*ForumMembership{}
|
||||||
|
result = (&ForumMembership{}).Preload().Where(
|
||||||
|
"forum_id = ? AND is_moderator IS TRUE",
|
||||||
|
f.ID,
|
||||||
|
).Find(&fm)
|
||||||
|
)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Error("Forum(%d).GetModerators(): %s", f.ID, result.Error)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load these users.
|
||||||
|
var userIDs = []uint64{}
|
||||||
|
for _, row := range fm {
|
||||||
|
userIDs = append(userIDs, row.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetUsersAlphabetically(userIDs)
|
||||||
|
}
|
||||||
|
|
||||||
// IsForumSubscribed checks if the current user subscribes to this forum.
|
// IsForumSubscribed checks if the current user subscribes to this forum.
|
||||||
func IsForumSubscribed(user *User, forum *Forum) bool {
|
func IsForumSubscribed(user *User, forum *Forum) bool {
|
||||||
f, _ := GetForumMembership(user, forum)
|
f, _ := GetForumMembership(user, forum)
|
||||||
return f.UserID == user.ID
|
return f.UserID == user.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasForumSubscriptions returns if the current user has at least one forum membership.
|
||||||
|
func (u *User) HasForumSubscriptions() bool {
|
||||||
|
var count int64
|
||||||
|
DB.Model(&ForumMembership{}).Where(
|
||||||
|
"user_id = ?",
|
||||||
|
u.ID,
|
||||||
|
).Count(&count)
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save a forum membership.
|
||||||
|
func (f *ForumMembership) Save() error {
|
||||||
|
return DB.Save(f).Error
|
||||||
|
}
|
||||||
|
|
||||||
// Delete a forum membership.
|
// Delete a forum membership.
|
||||||
func (f *ForumMembership) Delete() error {
|
func (f *ForumMembership) Delete() error {
|
||||||
return DB.Delete(f).Error
|
return DB.Delete(f).Error
|
||||||
|
|
|
@ -22,7 +22,7 @@ type RecentPost struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaginateRecentPosts returns all of the comments on a forum paginated.
|
// PaginateRecentPosts returns all of the comments on a forum paginated.
|
||||||
func PaginateRecentPosts(user *User, categories []string, allComments bool, pager *Pagination) ([]*RecentPost, error) {
|
func PaginateRecentPosts(user *User, categories []string, subscribed, allComments bool, pager *Pagination) ([]*RecentPost, error) {
|
||||||
var (
|
var (
|
||||||
result = []*RecentPost{}
|
result = []*RecentPost{}
|
||||||
blockedUserIDs = BlockedUserIDs(user)
|
blockedUserIDs = BlockedUserIDs(user)
|
||||||
|
@ -52,6 +52,19 @@ func PaginateRecentPosts(user *User, categories []string, allComments bool, page
|
||||||
wheres = append(wheres, "forums.private is not true")
|
wheres = append(wheres, "forums.private is not true")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forums I follow?
|
||||||
|
if subscribed {
|
||||||
|
wheres = append(wheres, `
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM forum_memberships
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND forum_id = forums.id
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
placeholders = append(placeholders, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// Blocked users?
|
// Blocked users?
|
||||||
if len(blockedUserIDs) > 0 {
|
if len(blockedUserIDs) > 0 {
|
||||||
comment_wheres = append(comment_wheres, "comments.user_id NOT IN ?")
|
comment_wheres = append(comment_wheres, "comments.user_id NOT IN ?")
|
||||||
|
|
|
@ -45,7 +45,8 @@ const (
|
||||||
NotificationCertApproved NotificationType = "cert_approved"
|
NotificationCertApproved NotificationType = "cert_approved"
|
||||||
NotificationPrivatePhoto NotificationType = "private_photo" // private photo grants
|
NotificationPrivatePhoto NotificationType = "private_photo" // private photo grants
|
||||||
NotificationNewPhoto NotificationType = "new_photo"
|
NotificationNewPhoto NotificationType = "new_photo"
|
||||||
NotificationCustom NotificationType = "custom" // custom message pushed
|
NotificationForumModerator NotificationType = "forum_moderator" // appointed as a forum moderator
|
||||||
|
NotificationCustom NotificationType = "custom" // custom message pushed
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateNotification inserts a new notification into the database.
|
// CreateNotification inserts a new notification into the database.
|
||||||
|
@ -372,9 +373,11 @@ func (n *Notification) Delete() error {
|
||||||
type NotificationBody struct {
|
type NotificationBody struct {
|
||||||
PhotoID uint64
|
PhotoID uint64
|
||||||
ThreadID uint64
|
ThreadID uint64
|
||||||
|
ForumID uint64
|
||||||
CommentID uint64
|
CommentID uint64
|
||||||
Photo *Photo
|
Photo *Photo
|
||||||
Thread *Thread
|
Thread *Thread
|
||||||
|
Forum *Forum
|
||||||
Comment *Comment
|
Comment *Comment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -403,6 +406,7 @@ func MapNotifications(ns []*Notification) NotificationMap {
|
||||||
|
|
||||||
result.mapNotificationPhotos(IDs)
|
result.mapNotificationPhotos(IDs)
|
||||||
result.mapNotificationThreads(IDs)
|
result.mapNotificationThreads(IDs)
|
||||||
|
result.mapNotificationForums(IDs)
|
||||||
|
|
||||||
// NOTE: comment loading is not used - was added when trying to add "Like" buttons inside
|
// NOTE: comment loading is not used - was added when trying to add "Like" buttons inside
|
||||||
// your Comment notifications. But when a photo is commented on, the notification table_name=photos,
|
// your Comment notifications. But when a photo is commented on, the notification table_name=photos,
|
||||||
|
@ -507,6 +511,53 @@ func (nm NotificationMap) mapNotificationThreads(IDs []uint64) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function of MapNotifications to eager load Forum attachments.
|
||||||
|
func (nm NotificationMap) mapNotificationForums(IDs []uint64) {
|
||||||
|
type scanner struct {
|
||||||
|
ForumID uint64
|
||||||
|
NotificationID uint64
|
||||||
|
}
|
||||||
|
var scan []scanner
|
||||||
|
|
||||||
|
// Load all of these that have forums.
|
||||||
|
err := DB.Table(
|
||||||
|
"notifications",
|
||||||
|
).Joins(
|
||||||
|
"JOIN forums ON (notifications.table_name='forums' AND notifications.table_id=forums.id)",
|
||||||
|
).Select(
|
||||||
|
"forums.id AS forum_id",
|
||||||
|
"notifications.id AS notification_id",
|
||||||
|
).Where(
|
||||||
|
"notifications.id IN ?",
|
||||||
|
IDs,
|
||||||
|
).Scan(&scan)
|
||||||
|
if err.Error != nil {
|
||||||
|
log.Error("Couldn't select forum IDs for notifications: %s", err.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect and load all the forums by ID.
|
||||||
|
var forumIDs = []uint64{}
|
||||||
|
for _, row := range scan {
|
||||||
|
// Store the forum ID in the result now.
|
||||||
|
nm[row.NotificationID].ForumID = row.ForumID
|
||||||
|
forumIDs = append(forumIDs, row.ForumID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the forums.
|
||||||
|
if len(forumIDs) > 0 {
|
||||||
|
if forums, err := GetForums(forumIDs); err != nil {
|
||||||
|
log.Error("Couldn't load forum IDs for notifications: %s", err)
|
||||||
|
} else {
|
||||||
|
// Marry them to their notification IDs.
|
||||||
|
for _, body := range nm {
|
||||||
|
if forum, ok := forums[body.ForumID]; ok {
|
||||||
|
body.Forum = forum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function of MapNotifications to eager load Comment attachments.
|
// Helper function of MapNotifications to eager load Comment attachments.
|
||||||
func (nm NotificationMap) mapNotificationComments(IDs []uint64) {
|
func (nm NotificationMap) mapNotificationComments(IDs []uint64) {
|
||||||
type scanner struct {
|
type scanner struct {
|
||||||
|
|
|
@ -125,6 +125,25 @@ func GetUsers(currentUser *User, userIDs []uint64) ([]*User, error) {
|
||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUsersAlphabetically queries for multiple user IDs and returns them sorted by username.
|
||||||
|
//
|
||||||
|
// Note: it doesn't respect blocked lists or a viewer context. Used for things like the forum moderators lists.
|
||||||
|
func GetUsersAlphabetically(userIDs []uint64) ([]*User, error) {
|
||||||
|
var (
|
||||||
|
users = []*User{}
|
||||||
|
result = (&User{}).Preload().Where(
|
||||||
|
"id IN ?", userIDs,
|
||||||
|
).Order("username asc").Find(&users)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Inject user relationships.
|
||||||
|
for _, user := range users {
|
||||||
|
SetUserRelationships(user, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
// GetUsersByUsernames queries for multiple usernames and returns users in the same order.
|
// GetUsersByUsernames queries for multiple usernames and returns users in the same order.
|
||||||
func GetUsersByUsernames(currentUser *User, usernames []string) ([]*User, error) {
|
func GetUsersByUsernames(currentUser *User, usernames []string) ([]*User, error) {
|
||||||
// Map the usernames.
|
// Map the usernames.
|
||||||
|
|
|
@ -87,12 +87,15 @@ func New() http.Handler {
|
||||||
mux.Handle("GET /forum", middleware.CertRequired(forum.Landing()))
|
mux.Handle("GET /forum", middleware.CertRequired(forum.Landing()))
|
||||||
mux.Handle("/forum/post", middleware.CertRequired(forum.NewPost()))
|
mux.Handle("/forum/post", middleware.CertRequired(forum.NewPost()))
|
||||||
mux.Handle("GET /forum/thread/{id}", middleware.CertRequired(forum.Thread()))
|
mux.Handle("GET /forum/thread/{id}", middleware.CertRequired(forum.Thread()))
|
||||||
mux.Handle("GET /forum/browse", middleware.CertRequired(forum.Browse()))
|
mux.Handle("GET /forum/explore", middleware.CertRequired(forum.Explore()))
|
||||||
mux.Handle("GET /forum/newest", middleware.CertRequired(forum.Newest()))
|
mux.Handle("GET /forum/newest", middleware.CertRequired(forum.Newest()))
|
||||||
mux.Handle("GET /forum/search", middleware.CertRequired(forum.Search()))
|
mux.Handle("GET /forum/search", middleware.CertRequired(forum.Search()))
|
||||||
mux.Handle("POST /forum/subscribe", middleware.CertRequired(forum.Subscribe()))
|
mux.Handle("POST /forum/subscribe", middleware.CertRequired(forum.Subscribe()))
|
||||||
mux.Handle("GET /f/{fragment}", middleware.CertRequired(forum.Forum()))
|
mux.Handle("GET /f/{fragment}", middleware.CertRequired(forum.Forum()))
|
||||||
mux.Handle("POST /poll/vote", middleware.CertRequired(poll.Vote()))
|
mux.Handle("POST /poll/vote", middleware.CertRequired(poll.Vote()))
|
||||||
|
mux.Handle("/forum/admin", middleware.CertRequired(forum.Manage()))
|
||||||
|
mux.Handle("/forum/admin/edit", middleware.CertRequired(forum.AddEdit()))
|
||||||
|
mux.Handle("/forum/admin/moderator", middleware.CertRequired(forum.ManageModerators()))
|
||||||
|
|
||||||
// Admin endpoints.
|
// Admin endpoints.
|
||||||
mux.Handle("GET /admin", middleware.AdminRequired("", admin.Dashboard()))
|
mux.Handle("GET /admin", middleware.AdminRequired("", admin.Dashboard()))
|
||||||
|
@ -102,8 +105,6 @@ func New() http.Handler {
|
||||||
mux.Handle("/admin/user-action", middleware.AdminRequired("", admin.UserActions()))
|
mux.Handle("/admin/user-action", middleware.AdminRequired("", admin.UserActions()))
|
||||||
mux.Handle("/admin/maintenance", middleware.AdminRequired(config.ScopeMaintenance, admin.Maintenance()))
|
mux.Handle("/admin/maintenance", middleware.AdminRequired(config.ScopeMaintenance, admin.Maintenance()))
|
||||||
mux.Handle("/admin/add-user", middleware.AdminRequired(config.ScopeUserCreate, admin.AddUser()))
|
mux.Handle("/admin/add-user", middleware.AdminRequired(config.ScopeUserCreate, admin.AddUser()))
|
||||||
mux.Handle("/forum/admin", middleware.CertRequired(forum.Manage()))
|
|
||||||
mux.Handle("/forum/admin/edit", middleware.CertRequired(forum.AddEdit()))
|
|
||||||
mux.Handle("/admin/photo/mark-explicit", middleware.AdminRequired("", admin.MarkPhotoExplicit()))
|
mux.Handle("/admin/photo/mark-explicit", middleware.AdminRequired("", admin.MarkPhotoExplicit()))
|
||||||
mux.Handle("GET /admin/changelog", middleware.AdminRequired(config.ScopeChangeLog, admin.ChangeLog()))
|
mux.Handle("GET /admin/changelog", middleware.AdminRequired(config.ScopeChangeLog, admin.ChangeLog()))
|
||||||
|
|
||||||
|
|
|
@ -492,7 +492,7 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div class="mb-1">
|
<div class="mb-1 pr-4">
|
||||||
{{if eq .Type "like"}}
|
{{if eq .Type "like"}}
|
||||||
<span class="icon"><i class="fa fa-heart has-text-danger"></i></span>
|
<span class="icon"><i class="fa fa-heart has-text-danger"></i></span>
|
||||||
<span>
|
<span>
|
||||||
|
@ -594,6 +594,12 @@
|
||||||
<span>
|
<span>
|
||||||
Your <strong>certification photo</strong> was rejected!
|
Your <strong>certification photo</strong> was rejected!
|
||||||
</span>
|
</span>
|
||||||
|
{{else if eq .Type "forum_moderator"}}
|
||||||
|
<span class="icon"><i class="fa fa-peace has-text-success"></i></span>
|
||||||
|
<span>
|
||||||
|
You have been appointed as a <strong class="has-text-success">moderator</strong>
|
||||||
|
for the forum <a href="/f/{{$Body.Forum.Fragment}}">{{$Body.Forum.Title}}</a>!
|
||||||
|
</span>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{.AboutUser.Username}} {{.Type}} {{.TableName}} {{.TableID}}
|
{{.AboutUser.Username}} {{.Type}} {{.TableName}} {{.TableID}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{{define "title"}}Forums{{end}}
|
{{define "title"}}Forums{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<section class="hero is-light is-danger">
|
<section class="hero is-bold is-primary">
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="title">
|
<h1 class="title">
|
||||||
|
@ -14,12 +14,30 @@
|
||||||
|
|
||||||
{{$Root := .}}
|
{{$Root := .}}
|
||||||
|
|
||||||
|
<div class="block p-4">
|
||||||
|
<div class="columns is-multiline is-mobile">
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<a href="/forum/admin">
|
||||||
|
<i class="fa fa-book"></i> My Forums
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{if .EditForum}}
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<a href="/f/{{.EditForum.Fragment}}">
|
||||||
|
<i class="fa fa-comments"></i>
|
||||||
|
Go to Forum
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="block p-4">
|
<div class="block p-4">
|
||||||
<div class="columns is-centered">
|
<div class="columns is-centered">
|
||||||
<div class="column is-two-thirds">
|
<div class="column is-two-thirds">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<header class="card-header has-background-info">
|
<header class="card-header has-background-info">
|
||||||
<p class="card-header-title has-text-light">Forum Properties</p>
|
<p class="card-header-title">Forum Properties</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
|
@ -96,11 +114,11 @@
|
||||||
Explicit <i class="fa fa-fire has-text-danger ml-1"></i>
|
Explicit <i class="fa fa-fire has-text-danger ml-1"></i>
|
||||||
</label>
|
</label>
|
||||||
<p class="help">
|
<p class="help">
|
||||||
Check this box if the forum is intended for explicit content. Users must
|
Check this box if the forum is intended for explicit content. Only members who have opted-in
|
||||||
opt-in to see explicit content.
|
to see explicit content can find this forum.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{{if .CurrentUser.IsAdmin}}
|
{{if .CurrentUser.HasAdminScope "admin.forum.manage"}}
|
||||||
<label class="checkbox mt-3">
|
<label class="checkbox mt-3">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="privileged"
|
name="privileged"
|
||||||
|
@ -115,26 +133,26 @@
|
||||||
</p>
|
</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .CurrentUser.IsAdmin}}
|
{{if .CurrentUser.HasAdminScope "admin.forum.manage"}}
|
||||||
<label class="checkbox mt-3">
|
<label class="checkbox mt-3">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="permit_photos"
|
name="permit_photos"
|
||||||
value="true"
|
value="true"
|
||||||
{{if and .EditForum .EditForum.PermitPhotos}}checked{{end}}>
|
{{if and .EditForum .EditForum.PermitPhotos}}checked{{end}}>
|
||||||
Permit Photos <i class="fa fa-camera ml-1"></i>
|
Permit Photos <i class="fa fa-camera ml-1"></i> <i class="fa fa-peace has-text-danger ml-1"></i>
|
||||||
</label>
|
</label>
|
||||||
<p class="help">
|
<p class="help">
|
||||||
Check this box if the forum allows photos to be uploaded (not implemented)
|
Check this box if the forum allows photos to be uploaded (not implemented)
|
||||||
</p>
|
</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .CurrentUser.IsAdmin}}
|
{{if .CurrentUser.HasAdminScope "admin.forum.manage"}}
|
||||||
<label class="checkbox mt-3">
|
<label class="checkbox mt-3">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="private"
|
name="private"
|
||||||
value="true"
|
value="true"
|
||||||
{{if and .EditForum .EditForum.Private}}checked{{end}}>
|
{{if and .EditForum .EditForum.Private}}checked{{end}}>
|
||||||
Private forum
|
Private forum <i class="fa fa-peace has-text-danger ml-1"></i>
|
||||||
</label>
|
</label>
|
||||||
<p class="help">
|
<p class="help">
|
||||||
This forum is only visible to admins or approved subscribers.
|
This forum is only visible to admins or approved subscribers.
|
||||||
|
@ -142,6 +160,45 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- On "Edit" of existing forum: show and allow adding Moderators. -->
|
||||||
|
{{if .EditForum}}
|
||||||
|
<hr>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Forum Moderators</label>
|
||||||
|
|
||||||
|
{{if .Moderators}}
|
||||||
|
<table class="table">
|
||||||
|
<tbody>
|
||||||
|
{{range .Moderators}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{template "avatar-16x16" .}}
|
||||||
|
<a href="/u/{{.Username}}">{{.Username}}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="/forum/admin/moderator?forum_id={{$Root.EditForum.ID}}&intent=remove&to={{.Username}}"
|
||||||
|
class="has-text-danger"
|
||||||
|
aria-label="Remove">
|
||||||
|
<i class="fa fa-trash"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<a href="/forum/admin/moderator?forum_id={{.EditForum.ID}}">
|
||||||
|
<i class="fa fa-plus"></i> Appoint a moderator
|
||||||
|
</a>
|
||||||
|
<p class="help">
|
||||||
|
You may appoint other members from the {{PrettyTitle}} community to help you moderate your forum.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<button type="submit" class="button is-success">
|
<button type="submit" class="button is-success">
|
||||||
{{if .EditForum}}Save Forum{{else}}Create Forum{{end}}
|
{{if .EditForum}}Save Forum{{else}}Create Forum{{end}}
|
||||||
|
|
|
@ -57,6 +57,13 @@
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
|
<!-- Edit button for forum's settings -->
|
||||||
|
{{if or (eq .Forum.OwnerID .CurrentUser.ID) (.CurrentUser.HasAdminScope "admin.forum.manage")}}
|
||||||
|
<a href="/forum/admin/edit?id={{.Forum.ID}}" class="button mr-2">
|
||||||
|
<span class="icon"><i class="fa fa-gear"></i></span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if or .CurrentUser.IsAdmin (not .Forum.Privileged) (eq .Forum.OwnerID .CurrentUser.ID)}}
|
{{if or .CurrentUser.IsAdmin (not .Forum.Privileged) (eq .Forum.OwnerID .CurrentUser.ID)}}
|
||||||
<a href="/forum/post?to={{.Forum.Fragment}}" class="button is-info">
|
<a href="/forum/post?to={{.Forum.Fragment}}" class="button is-info">
|
||||||
<span class="icon"><i class="fa fa-plus"></i></span>
|
<span class="icon"><i class="fa fa-plus"></i></span>
|
||||||
|
@ -83,6 +90,20 @@
|
||||||
{{SimplePager .Pager}}
|
{{SimplePager .Pager}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- If no posts at all? -->
|
||||||
|
{{if not .Threads}}
|
||||||
|
<div class="block p-2">
|
||||||
|
<div class="notification is-info is-light content">
|
||||||
|
<p>
|
||||||
|
There are no posts yet on this forum.
|
||||||
|
{{if not .Forum.Privileged}}
|
||||||
|
Why not <a href="/forum/post?to={{.Forum.Fragment}}">start the first thread?</a>
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{$Root := .}}
|
{{$Root := .}}
|
||||||
<div class="block p-2">
|
<div class="block p-2">
|
||||||
{{range .Threads}}
|
{{range .Threads}}
|
||||||
|
@ -172,4 +193,63 @@
|
||||||
{{SimplePager .Pager}}
|
{{SimplePager .Pager}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Info box: owner, moderators -->
|
||||||
|
<div class="block p-2">
|
||||||
|
<div class="box">
|
||||||
|
|
||||||
|
<label class="label">Forum Info</label>
|
||||||
|
<div class="mb-4">
|
||||||
|
Created: <span title="{{.Forum.CreatedAt}}">{{.Forum.CreatedAt.Format "Jan _2 2006"}}</span>
|
||||||
|
<div class="mt-2">
|
||||||
|
{{if .Forum.Explicit}}
|
||||||
|
<span class="tag is-danger is-light">
|
||||||
|
<span class="icon"><i class="fa fa-fire"></i></span>
|
||||||
|
<span>Explicit</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Forum.Privileged}}
|
||||||
|
<span class="tag is-warning is-light">
|
||||||
|
<span class="icon"><i class="fa fa-peace"></i></span>
|
||||||
|
<span>Privileged</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Forum.PermitPhotos}}
|
||||||
|
<span class="tag is-grey">
|
||||||
|
<span class="icon"><i class="fa fa-camera"></i></span>
|
||||||
|
<span>Photos</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Forum.Private}}
|
||||||
|
<span class="tag is-private is-light">
|
||||||
|
<span class="icon"><i class="fa fa-lock"></i></span>
|
||||||
|
<span>Private</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="label">Forum Moderators</label>
|
||||||
|
|
||||||
|
<!-- The owner first -->
|
||||||
|
{{template "avatar-16x16" .Forum.Owner}}
|
||||||
|
<a href="/u/{{.Forum.Owner.Username}}">
|
||||||
|
{{.Forum.Owner.Username}}
|
||||||
|
</a>
|
||||||
|
<small class="has-text-grey">(owner)</small>
|
||||||
|
|
||||||
|
<!-- Additional moderators -->
|
||||||
|
{{range .ForumModerators}}
|
||||||
|
<span class="pl-2">
|
||||||
|
{{template "avatar-16x16" .}}
|
||||||
|
<a href="/u/{{.Username}}">
|
||||||
|
{{.Username}}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
@ -32,8 +32,8 @@
|
||||||
<!-- Tab bar -->
|
<!-- Tab bar -->
|
||||||
{{template "ForumTabs" .}}
|
{{template "ForumTabs" .}}
|
||||||
|
|
||||||
<!-- Filters for the Browse tab -->
|
<!-- Filters for the Explore tab -->
|
||||||
{{if .IsBrowseTab}}
|
{{if .IsExploreTab}}
|
||||||
<div class="block mb-0 p-4">
|
<div class="block mb-0 p-4">
|
||||||
<form action="{{.Request.URL.Path}}" method="GET">
|
<form action="{{.Request.URL.Path}}" method="GET">
|
||||||
|
|
||||||
|
@ -251,8 +251,8 @@
|
||||||
</div>
|
</div>
|
||||||
{{end}}<!-- range .Categories -->
|
{{end}}<!-- range .Categories -->
|
||||||
|
|
||||||
<!-- Pager footer for Browse tab -->
|
<!-- Pager footer for Explore tab -->
|
||||||
{{if .IsBrowseTab}}
|
{{if .IsExploreTab}}
|
||||||
<div class="block p-4">
|
<div class="block p-4">
|
||||||
{{SimplePager .Pager}}
|
{{SimplePager .Pager}}
|
||||||
</div>
|
</div>
|
||||||
|
|
138
web/templates/forum/moderators.html
Normal file
138
web/templates/forum/moderators.html
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
{{define "title"}}Manage Forum Moderators{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero is-warning is-bold">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">
|
||||||
|
Manage Forum Moderators
|
||||||
|
</h1>
|
||||||
|
<h2 class="subtitle">
|
||||||
|
For {{.Forum.Title}} <small class="ml-2">/f/{{.Forum.Fragment}}</small>
|
||||||
|
</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 {{if .IsRemoving}}has-background-danger{{else}}has-background-link{{end}}">
|
||||||
|
<p class="card-header-title has-text-light">
|
||||||
|
<span class="icon mr-2"><i class="fa fa-user-tie"></i></span>
|
||||||
|
<span>
|
||||||
|
{{if .IsRemoving}}
|
||||||
|
Remove a Moderator
|
||||||
|
{{else}}
|
||||||
|
Appoint a Moderator
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div class="card-content">
|
||||||
|
|
||||||
|
<!-- Show the introduction or selected user profile -->
|
||||||
|
{{if not .User}}
|
||||||
|
<div class="block">
|
||||||
|
You may use this page to <strong>appoint a moderator</strong> to help you
|
||||||
|
manage your forum. This can be <em>anybody</em> from the {{PrettyTitle}} community.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
Moderators will be able to delete threads and replies on your forum.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
As the owner of the forum, you retain full control over its settings and you alone
|
||||||
|
can add or remove its moderators.
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="block">
|
||||||
|
{{if .IsRemoving}}
|
||||||
|
Confirm that you wish to <strong>remove</strong> moderator rights for
|
||||||
|
{{.User.Username}} on your forum.
|
||||||
|
{{else}}
|
||||||
|
Confirm that you wish to grant <strong>{{.User.Username}}</strong>
|
||||||
|
access to moderate your forum by clicking the button below.
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="media block">
|
||||||
|
<div class="media-left">
|
||||||
|
{{template "avatar-64x64" .User}}
|
||||||
|
</div>
|
||||||
|
<div class="media-content">
|
||||||
|
<p class="title is-4">{{.User.NameOrUsername}}</p>
|
||||||
|
<p class="subtitle is-6">
|
||||||
|
<span class="icon"><i class="fa fa-user"></i></span>
|
||||||
|
<a href="/u/{{.User.Username}}" target="_blank">{{.User.Username}}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Show the relevant form -->
|
||||||
|
<form action="/forum/admin/moderator" method="POST">
|
||||||
|
{{InputCSRF}}
|
||||||
|
<input type="hidden" name="forum_id" value="{{.Forum.ID}}">
|
||||||
|
|
||||||
|
{{if .User}}
|
||||||
|
<input type="hidden" name="to" value="{{.User.Username}}">
|
||||||
|
{{else}}
|
||||||
|
<div class="field block">
|
||||||
|
<label for="to" class="label">Add a moderator by their username:</label>
|
||||||
|
<input type="text" class="input"
|
||||||
|
name="to" id="to"
|
||||||
|
placeholder="username">
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="field has-text-centered">
|
||||||
|
{{if .IsRemoving}}
|
||||||
|
<button type="submit" class="button is-danger"
|
||||||
|
name="intent"
|
||||||
|
value="{{if .User}}confirm-remove{{else}}preview{{end}}">
|
||||||
|
{{if .User}}
|
||||||
|
<span class="icon"><i class="fa fa-user-xmark"></i></span>
|
||||||
|
<span>Remove from moderators</span>
|
||||||
|
{{else}}
|
||||||
|
Continue
|
||||||
|
{{end}}
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
<button type="submit" class="button is-success"
|
||||||
|
name="intent"
|
||||||
|
value="{{if .User}}submit{{else}}preview{{end}}">
|
||||||
|
{{if .User}}
|
||||||
|
<span class="icon"><i class="fa fa-user-tie"></i></span>
|
||||||
|
<span>Add to moderators</span>
|
||||||
|
{{else}}
|
||||||
|
Continue
|
||||||
|
{{end}}
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</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}}
|
|
@ -19,19 +19,71 @@
|
||||||
<!-- Tab bar -->
|
<!-- Tab bar -->
|
||||||
{{template "ForumTabs" .}}
|
{{template "ForumTabs" .}}
|
||||||
|
|
||||||
<div class="p-4">
|
<!-- Display Options -->
|
||||||
Found {{FormatNumberCommas .Pager.Total}} {{if .AllComments}}posts{{else}}threads{{end}} (page {{.Pager.Page}} of {{.Pager.Pages}})
|
<div class="px-4">
|
||||||
|
<div class="level">
|
||||||
|
<div class="level-left">
|
||||||
|
<div class="level-item">
|
||||||
|
Found {{FormatNumberCommas .Pager.Total}} {{if .AllComments}}posts{{else}}threads{{end}} (page {{.Pager.Page}} of {{.Pager.Pages}})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="level-right">
|
||||||
{{if not .AllComments}}
|
<div class="level-item">
|
||||||
<!-- Default view is to deduplicate and show only threads and their newest comment -->
|
<div class="columns align-right">
|
||||||
Showing only the latest comment per thread. <a href="?{{QueryPlus "all" "true"}}">Show all comments instead?</a>
|
|
||||||
{{else}}
|
{{if .FeatureUserForumsEnabled}}
|
||||||
Showing <strong>all</strong> forum posts by most recent. <a href="{{.Request.URL.Path}}">Deduplicate by thread?</a>
|
<div class="column is-narrow">
|
||||||
{{end}}
|
<label class="label">Which forums:</label>
|
||||||
|
<div class="tabs is-toggle is-small">
|
||||||
|
<ul>
|
||||||
|
<li {{if eq .WhichForums "all"}}class="is-active"{{end}}>
|
||||||
|
<a href="{{.Request.URL.Path}}?{{QueryPlus "which" "all"}}">All</a>
|
||||||
|
</li>
|
||||||
|
<li {{if eq .WhichForums "official"}}class="is-active"{{end}}>
|
||||||
|
<a href="{{.Request.URL.Path}}?{{QueryPlus "which" "official"}}">Official</a>
|
||||||
|
</li>
|
||||||
|
<li {{if eq .WhichForums "community"}}class="is-active"{{end}}>
|
||||||
|
<a href="{{.Request.URL.Path}}?{{QueryPlus "which" "community"}}">Community</a>
|
||||||
|
</li>
|
||||||
|
{{if .CurrentUser.HasForumSubscriptions}}
|
||||||
|
<li {{if eq .WhichForums "followed"}}class="is-active"{{end}}>
|
||||||
|
<a href="{{.Request.URL.Path}}?{{QueryPlus "which" "followed"}}">My List</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<label class="label">Show:</label>
|
||||||
|
<div class="tabs is-toggle is-small">
|
||||||
|
<ul>
|
||||||
|
<li {{if not .AllComments}}class="is-active"{{end}}>
|
||||||
|
<a href="{{.Request.URL.Path}}?{{QueryPlus "all" ""}}">By thread</a>
|
||||||
|
</li>
|
||||||
|
<li {{if .AllComments}}class="is-active"{{end}}>
|
||||||
|
<a href="{{.Request.URL.Path}}?{{QueryPlus "all" "true"}}">All posts</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4">
|
||||||
|
{{if not .AllComments}}
|
||||||
|
<!-- Default view is to deduplicate and show only threads and their newest comment -->
|
||||||
|
Showing only the latest comment per thread. <a href="?{{QueryPlus "all" "true"}}">Show all comments instead?</a>
|
||||||
|
{{else}}
|
||||||
|
Showing <strong>all</strong> forum posts by most recent. <a href="{{.Request.URL.Path}}?{{QueryPlus "all" ""}}">Deduplicate by thread?</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
{{SimplePager .Pager}}
|
{{SimplePager .Pager}}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,10 +4,39 @@
|
||||||
<section class="hero is-light is-success">
|
<section class="hero is-light is-success">
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="title">
|
<div class="level">
|
||||||
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
|
<div class="level-left mb-4">
|
||||||
<span>{{.Forum.Title}}</span>
|
<h1 class="title">
|
||||||
</h1>
|
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
|
||||||
|
<span>{{.Forum.Title}}</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .FeatureUserForumsEnabled}}
|
||||||
|
<div class="level-right">
|
||||||
|
<!-- Follow/Unfollow This Forum -->
|
||||||
|
<form action="/forum/subscribe" method="POST" class="is-inline">
|
||||||
|
{{InputCSRF}}
|
||||||
|
<input type="hidden" name="fragment" value="{{.Forum.Fragment}}">
|
||||||
|
|
||||||
|
{{if .IsForumSubscribed}}
|
||||||
|
<button type="submit" class="button"
|
||||||
|
name="intent" value="unfollow"
|
||||||
|
onclick="return confirm('Do you want to remove this forum from your list?')">
|
||||||
|
<span class="icon"><i class="fa fa-bookmark"></i></span>
|
||||||
|
<span>Followed</span>
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
<button type="submit" class="button"
|
||||||
|
name="intent" value="follow">
|
||||||
|
<span class="icon"><i class="fa-regular fa-bookmark"></i></span>
|
||||||
|
<span>Follow</span>
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -16,9 +16,9 @@ Variables that your template should set:
|
||||||
Categories
|
Categories
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li {{if eq .CurrentForumTab "browse" }}class="is-active"{{end}}>
|
<li {{if eq .CurrentForumTab "explore" }}class="is-active"{{end}}>
|
||||||
<a href="/forum/browse">
|
<a href="/forum/explore">
|
||||||
Browse
|
Explore
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li {{if eq .CurrentForumTab "newest" }}class="is-active"{{end}}>
|
<li {{if eq .CurrentForumTab "newest" }}class="is-active"{{end}}>
|
||||||
|
|
|
@ -1,5 +1,24 @@
|
||||||
<!-- User avatar widgets -->
|
<!-- User avatar widgets -->
|
||||||
|
|
||||||
|
<!-- Parameter: .User -->
|
||||||
|
{{define "avatar-16x16"}}
|
||||||
|
<figure class="image is-16x16 is-inline-block">
|
||||||
|
<a href="/u/{{.Username}}" class="has-text-dark">
|
||||||
|
{{if .ProfilePhoto.ID}}
|
||||||
|
{{if and (eq .ProfilePhoto.Visibility "private") (not .UserRelationship.IsPrivateGranted)}}
|
||||||
|
<img src="/static/img/shy-private.png" class="is-rounded">
|
||||||
|
{{else if and (eq .ProfilePhoto.Visibility "friends") (not .UserRelationship.IsFriend)}}
|
||||||
|
<img src="/static/img/shy-friends.png" class="is-rounded">
|
||||||
|
{{else}}
|
||||||
|
<img src="{{PhotoURL .ProfilePhoto.CroppedFilename}}" class="is-rounded">
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<img src="/static/img/shy.png" class="is-rounded">
|
||||||
|
{{end}}
|
||||||
|
</a>
|
||||||
|
</figure>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<!-- Parameter: .User -->
|
<!-- Parameter: .User -->
|
||||||
{{define "avatar-24x24"}}
|
{{define "avatar-24x24"}}
|
||||||
<figure class="image is-24x24 is-inline-block">
|
<figure class="image is-24x24 is-inline-block">
|
||||||
|
|
Loading…
Reference in New Issue
Block a user