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:
Noah Petherbridge 2024-08-21 21:53:35 -07:00
parent eb85b2e090
commit ed4a9f8c89
22 changed files with 816 additions and 63 deletions

View File

@ -45,7 +45,7 @@ func AddEdit() http.HandlerFunc {
return
} else {
// Do we have permission?
if found.OwnerID != currentUser.ID && !currentUser.IsAdmin {
if !found.CanEdit(currentUser) {
templates.ForbiddenPage(w, r)
return
}
@ -67,8 +67,8 @@ func AddEdit() http.HandlerFunc {
isPrivate = r.PostFormValue("private") == "true"
)
// Sanity check admin-only settings.
if !currentUser.IsAdmin {
// Sanity check admin-only settings -> default these to OFF.
if !currentUser.HasAdminScope(config.ScopeForumAdmin) {
isPrivileged = false
isPermitPhotos = false
isPrivate = false
@ -81,18 +81,25 @@ func AddEdit() http.HandlerFunc {
models.NewFieldDiff("Description", forum.Description, description),
models.NewFieldDiff("Category", forum.Category, category),
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.Description = description
forum.Category = category
forum.Explicit = isExplicit
forum.Privileged = isPrivileged
forum.PermitPhotos = isPermitPhotos
forum.Private = isPrivate
// Forum Admin-only options: if the current viewer is not a forum admin, do not change these settings.
// 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.
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{}{
"EditID": editID,
"EditForum": forum,
"Categories": config.ForumCategories,
"Moderators": mods,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -9,8 +9,8 @@ import (
"code.nonshy.com/nonshy/website/pkg/templates"
)
// Browse all existing forums.
func Browse() http.HandlerFunc {
// Explore all existing forums.
func Explore() http.HandlerFunc {
// This page shares a template with the board index (Categories) page.
tmpl := templates.Must("forum/index.html")
@ -84,8 +84,8 @@ func Browse() http.HandlerFunc {
followMap := models.MapForumMemberships(currentUser, forums)
var vars = map[string]interface{}{
"CurrentForumTab": "browse",
"IsBrowseTab": true,
"CurrentForumTab": "explore",
"IsExploreTab": true,
"Pager": pager,
"Categories": categorized,
"ForumMap": forumMap,

View File

@ -4,6 +4,7 @@ import (
"net/http"
"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/session"
"code.nonshy.com/nonshy/website/pkg/templates"
@ -71,8 +72,15 @@ func Forum() http.HandlerFunc {
// Map the statistics (replies, views) of these 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{}{
"Forum": forum,
"ForumModerators": mods,
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
"Threads": threads,
"ThreadMap": threadMap,

View 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
}
})
}

View File

@ -17,6 +17,9 @@ func Newest() http.HandlerFunc {
// Query parameters.
var (
allComments = r.FormValue("all") == "true"
whichForums = r.FormValue("which")
categories = []string{}
subscribed bool
)
// Get the current user.
@ -27,6 +30,29 @@ func Newest() http.HandlerFunc {
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.
var pager = &models.Pagination{
Page: 1,
@ -34,7 +60,7 @@ func Newest() http.HandlerFunc {
}
pager.ParsePage(r)
posts, err := models.PaginateRecentPosts(currentUser, config.ForumCategories, allComments, pager)
posts, err := models.PaginateRecentPosts(currentUser, categories, subscribed, allComments, pager)
if err != nil {
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
templates.Redirect(w, "/")
@ -56,7 +82,10 @@ func Newest() http.HandlerFunc {
"Pager": pager,
"RecentPosts": posts,
"PhotoMap": photos,
"AllComments": allComments,
// Filter options.
"WhichForums": whichForums,
"AllComments": allComments,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -3,6 +3,7 @@ package forum
import (
"net/http"
"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"
@ -51,6 +52,13 @@ func Subscribe() http.HandlerFunc {
case "unfollow":
fm, err := models.GetForumMembership(currentUser, forum)
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()
if err != nil {
session.FlashError(w, r, "Couldn't delete your forum membership: %s", err)

View File

@ -101,14 +101,15 @@ func Thread() http.HandlerFunc {
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
var vars = map[string]interface{}{
"Forum": forum,
"Thread": thread,
"Comments": comments,
"LikeMap": commentLikeMap,
"PhotoMap": photos,
"Pager": pager,
"CanModerate": canModerate,
"IsSubscribed": isSubscribed,
"Forum": forum,
"Thread": thread,
"Comments": comments,
"LikeMap": commentLikeMap,
"PhotoMap": photos,
"Pager": pager,
"CanModerate": canModerate,
"IsSubscribed": isSubscribed,
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -5,6 +5,7 @@ import (
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"gorm.io/gorm"
)
@ -27,7 +28,7 @@ type Forum struct {
// Preload related tables for the forum (classmethod).
func (f *Forum) Preload() *gorm.DB {
return DB.Preload("Owner")
return DB.Preload("Owner").Preload("Owner.ProfilePhoto")
}
// GetForum by ID.
@ -69,6 +70,13 @@ func ForumByFragment(fragment string) (*Forum, 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.

View File

@ -1,6 +1,7 @@
package models
import (
"errors"
"strings"
"time"
@ -9,11 +10,13 @@ import (
)
// ForumMembership table.
//
// Unique key constraint pairs user_id and forum_id.
type ForumMembership struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"index"`
UserID uint64 `gorm:"uniqueIndex:idx_forum_membership"`
User User `gorm:"foreignKey:user_id"`
ForumID uint64 `gorm:"index"`
ForumID uint64 `gorm:"uniqueIndex:idx_forum_membership"`
Forum Forum `gorm:"foreignKey:forum_id"`
Approved bool `gorm:"index"`
IsModerator bool `gorm:"index"`
@ -39,7 +42,7 @@ func CreateForumMembership(user *User, forum *Forum) (*ForumMembership, 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) {
var (
f = &ForumMembership{}
@ -51,12 +54,88 @@ func GetForumMembership(user *User, forum *Forum) (*ForumMembership, 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.
func IsForumSubscribed(user *User, forum *Forum) bool {
f, _ := GetForumMembership(user, forum)
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.
func (f *ForumMembership) Delete() error {
return DB.Delete(f).Error

View File

@ -22,7 +22,7 @@ type RecentPost struct {
}
// 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 (
result = []*RecentPost{}
blockedUserIDs = BlockedUserIDs(user)
@ -52,6 +52,19 @@ func PaginateRecentPosts(user *User, categories []string, allComments bool, page
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?
if len(blockedUserIDs) > 0 {
comment_wheres = append(comment_wheres, "comments.user_id NOT IN ?")

View File

@ -45,7 +45,8 @@ const (
NotificationCertApproved NotificationType = "cert_approved"
NotificationPrivatePhoto NotificationType = "private_photo" // private photo grants
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.
@ -372,9 +373,11 @@ func (n *Notification) Delete() error {
type NotificationBody struct {
PhotoID uint64
ThreadID uint64
ForumID uint64
CommentID uint64
Photo *Photo
Thread *Thread
Forum *Forum
Comment *Comment
}
@ -403,6 +406,7 @@ func MapNotifications(ns []*Notification) NotificationMap {
result.mapNotificationPhotos(IDs)
result.mapNotificationThreads(IDs)
result.mapNotificationForums(IDs)
// 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,
@ -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.
func (nm NotificationMap) mapNotificationComments(IDs []uint64) {
type scanner struct {

View File

@ -125,6 +125,25 @@ func GetUsers(currentUser *User, userIDs []uint64) ([]*User, error) {
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.
func GetUsersByUsernames(currentUser *User, usernames []string) ([]*User, error) {
// Map the usernames.

View File

@ -87,12 +87,15 @@ func New() http.Handler {
mux.Handle("GET /forum", middleware.CertRequired(forum.Landing()))
mux.Handle("/forum/post", middleware.CertRequired(forum.NewPost()))
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/search", middleware.CertRequired(forum.Search()))
mux.Handle("POST /forum/subscribe", middleware.CertRequired(forum.Subscribe()))
mux.Handle("GET /f/{fragment}", middleware.CertRequired(forum.Forum()))
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.
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/maintenance", middleware.AdminRequired(config.ScopeMaintenance, admin.Maintenance()))
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("GET /admin/changelog", middleware.AdminRequired(config.ScopeChangeLog, admin.ChangeLog()))

View File

@ -492,7 +492,7 @@
</a>
</div>
<div class="column">
<div class="mb-1">
<div class="mb-1 pr-4">
{{if eq .Type "like"}}
<span class="icon"><i class="fa fa-heart has-text-danger"></i></span>
<span>
@ -594,6 +594,12 @@
<span>
Your <strong>certification photo</strong> was rejected!
</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}}
{{.AboutUser.Username}} {{.Type}} {{.TableName}} {{.TableID}}
{{end}}

View File

@ -1,7 +1,7 @@
{{define "title"}}Forums{{end}}
{{define "content"}}
<div class="block">
<section class="hero is-light is-danger">
<section class="hero is-bold is-primary">
<div class="hero-body">
<div class="container">
<h1 class="title">
@ -14,12 +14,30 @@
{{$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="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>
<p class="card-header-title">Forum Properties</p>
</header>
<div class="card-content">
@ -96,11 +114,11 @@
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.
Check this box if the forum is intended for explicit content. Only members who have opted-in
to see explicit content can find this forum.
</p>
{{if .CurrentUser.IsAdmin}}
{{if .CurrentUser.HasAdminScope "admin.forum.manage"}}
<label class="checkbox mt-3">
<input type="checkbox"
name="privileged"
@ -115,26 +133,26 @@
</p>
{{end}}
{{if .CurrentUser.IsAdmin}}
{{if .CurrentUser.HasAdminScope "admin.forum.manage"}}
<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>
Permit Photos <i class="fa fa-camera ml-1"></i> <i class="fa fa-peace has-text-danger ml-1"></i>
</label>
<p class="help">
Check this box if the forum allows photos to be uploaded (not implemented)
</p>
{{end}}
{{if .CurrentUser.IsAdmin}}
{{if .CurrentUser.HasAdminScope "admin.forum.manage"}}
<label class="checkbox mt-3">
<input type="checkbox"
name="private"
value="true"
{{if and .EditForum .EditForum.Private}}checked{{end}}>
Private forum
Private forum <i class="fa fa-peace has-text-danger ml-1"></i>
</label>
<p class="help">
This forum is only visible to admins or approved subscribers.
@ -142,6 +160,45 @@
{{end}}
</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">
<button type="submit" class="button is-success">
{{if .EditForum}}Save Forum{{else}}Create Forum{{end}}

View File

@ -57,6 +57,13 @@
</nav>
</div>
<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)}}
<a href="/forum/post?to={{.Forum.Fragment}}" class="button is-info">
<span class="icon"><i class="fa fa-plus"></i></span>
@ -83,6 +90,20 @@
{{SimplePager .Pager}}
</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 := .}}
<div class="block p-2">
{{range .Threads}}
@ -172,4 +193,63 @@
{{SimplePager .Pager}}
</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}}

View File

@ -32,8 +32,8 @@
<!-- Tab bar -->
{{template "ForumTabs" .}}
<!-- Filters for the Browse tab -->
{{if .IsBrowseTab}}
<!-- Filters for the Explore tab -->
{{if .IsExploreTab}}
<div class="block mb-0 p-4">
<form action="{{.Request.URL.Path}}" method="GET">
@ -251,8 +251,8 @@
</div>
{{end}}<!-- range .Categories -->
<!-- Pager footer for Browse tab -->
{{if .IsBrowseTab}}
<!-- Pager footer for Explore tab -->
{{if .IsExploreTab}}
<div class="block p-4">
{{SimplePager .Pager}}
</div>

View 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}}

View File

@ -19,19 +19,71 @@
<!-- Tab bar -->
{{template "ForumTabs" .}}
<div class="p-4">
Found {{FormatNumberCommas .Pager.Total}} {{if .AllComments}}posts{{else}}threads{{end}} (page {{.Pager.Page}} of {{.Pager.Pages}})
<!-- Display Options -->
<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">
{{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}}">Deduplicate by thread?</a>
{{end}}
<div class="level-right">
<div class="level-item">
<div class="columns align-right">
{{if .FeatureUserForumsEnabled}}
<div class="column is-narrow">
<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 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">
{{SimplePager .Pager}}
</div>

View File

@ -4,10 +4,39 @@
<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>{{.Forum.Title}}</span>
</h1>
<div class="level">
<div class="level-left mb-4">
<h1 class="title">
<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>
</section>

View File

@ -16,9 +16,9 @@ Variables that your template should set:
Categories
</a>
</li>
<li {{if eq .CurrentForumTab "browse" }}class="is-active"{{end}}>
<a href="/forum/browse">
Browse
<li {{if eq .CurrentForumTab "explore" }}class="is-active"{{end}}>
<a href="/forum/explore">
Explore
</a>
</li>
<li {{if eq .CurrentForumTab "newest" }}class="is-active"{{end}}>

View File

@ -1,5 +1,24 @@
<!-- 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 -->
{{define "avatar-24x24"}}
<figure class="image is-24x24 is-inline-block">