From 28d1e284ab069fb33ea9f558f6fbe74718a07037 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Wed, 21 Aug 2024 21:53:35 -0700 Subject: [PATCH] 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. --- pkg/controller/forum/add_edit.go | 35 ++++-- pkg/controller/forum/browse.go | 8 +- pkg/controller/forum/forum.go | 8 ++ pkg/controller/forum/moderators.go | 140 ++++++++++++++++++++++++ pkg/controller/forum/newest.go | 33 +++++- pkg/controller/forum/subscribe.go | 8 ++ pkg/controller/forum/thread.go | 17 +-- pkg/models/forum.go | 10 +- pkg/models/forum_membership.go | 85 +++++++++++++- pkg/models/forum_recent.go | 15 ++- pkg/models/notification.go | 53 ++++++++- pkg/models/user.go | 19 ++++ pkg/router/router.go | 7 +- web/templates/account/dashboard.html | 8 +- web/templates/forum/add_edit.html | 75 +++++++++++-- web/templates/forum/board_index.html | 80 ++++++++++++++ web/templates/forum/index.html | 8 +- web/templates/forum/moderators.html | 138 +++++++++++++++++++++++ web/templates/forum/newest.html | 70 ++++++++++-- web/templates/forum/thread.html | 37 ++++++- web/templates/partials/forum_tabs.html | 6 +- web/templates/partials/user_avatar.html | 19 ++++ 22 files changed, 816 insertions(+), 63 deletions(-) create mode 100644 pkg/controller/forum/moderators.go create mode 100644 web/templates/forum/moderators.html diff --git a/pkg/controller/forum/add_edit.go b/pkg/controller/forum/add_edit.go index 053d22a..ec60300 100644 --- a/pkg/controller/forum/add_edit.go +++ b/pkg/controller/forum/add_edit.go @@ -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) diff --git a/pkg/controller/forum/browse.go b/pkg/controller/forum/browse.go index 93a3125..18d814a 100644 --- a/pkg/controller/forum/browse.go +++ b/pkg/controller/forum/browse.go @@ -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, diff --git a/pkg/controller/forum/forum.go b/pkg/controller/forum/forum.go index 9e34c14..d939f92 100644 --- a/pkg/controller/forum/forum.go +++ b/pkg/controller/forum/forum.go @@ -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, diff --git a/pkg/controller/forum/moderators.go b/pkg/controller/forum/moderators.go new file mode 100644 index 0000000..0c1485c --- /dev/null +++ b/pkg/controller/forum/moderators.go @@ -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 + } + }) +} diff --git a/pkg/controller/forum/newest.go b/pkg/controller/forum/newest.go index e98581e..c3963aa 100644 --- a/pkg/controller/forum/newest.go +++ b/pkg/controller/forum/newest.go @@ -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) diff --git a/pkg/controller/forum/subscribe.go b/pkg/controller/forum/subscribe.go index 1eb782e..9c7998e 100644 --- a/pkg/controller/forum/subscribe.go +++ b/pkg/controller/forum/subscribe.go @@ -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) diff --git a/pkg/controller/forum/thread.go b/pkg/controller/forum/thread.go index 0f0a4ba..74a24f1 100644 --- a/pkg/controller/forum/thread.go +++ b/pkg/controller/forum/thread.go @@ -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) diff --git a/pkg/models/forum.go b/pkg/models/forum.go index 6b04a36..3db2b8a 100644 --- a/pkg/models/forum.go +++ b/pkg/models/forum.go @@ -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. diff --git a/pkg/models/forum_membership.go b/pkg/models/forum_membership.go index af771eb..1eee49b 100644 --- a/pkg/models/forum_membership.go +++ b/pkg/models/forum_membership.go @@ -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 diff --git a/pkg/models/forum_recent.go b/pkg/models/forum_recent.go index 5f3beb0..e997acd 100644 --- a/pkg/models/forum_recent.go +++ b/pkg/models/forum_recent.go @@ -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 ?") diff --git a/pkg/models/notification.go b/pkg/models/notification.go index 0670af6..32b73a1 100644 --- a/pkg/models/notification.go +++ b/pkg/models/notification.go @@ -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 { diff --git a/pkg/models/user.go b/pkg/models/user.go index 8942747..8d911a2 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -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. diff --git a/pkg/router/router.go b/pkg/router/router.go index 0d2c34d..1b62f49 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -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())) diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index eed0c40..a97cb46 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -492,7 +492,7 @@
-
+
{{if eq .Type "like"}} @@ -594,6 +594,12 @@ Your certification photo was rejected! + {{else if eq .Type "forum_moderator"}} + + + You have been appointed as a moderator + for the forum {{$Body.Forum.Title}}! + {{else}} {{.AboutUser.Username}} {{.Type}} {{.TableName}} {{.TableID}} {{end}} diff --git a/web/templates/forum/add_edit.html b/web/templates/forum/add_edit.html index a78243f..35ec208 100644 --- a/web/templates/forum/add_edit.html +++ b/web/templates/forum/add_edit.html @@ -1,7 +1,7 @@ {{define "title"}}Forums{{end}} {{define "content"}}
-
+

@@ -14,12 +14,30 @@ {{$Root := .}} +
+
+ + {{if .EditForum}} + + {{end}} +
+
+
-

Forum Properties

+

Forum Properties

@@ -96,11 +114,11 @@ Explicit

- 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.

- {{if .CurrentUser.IsAdmin}} + {{if .CurrentUser.HasAdminScope "admin.forum.manage"}}
+ + {{if .EditForum}} +
+
+ + + {{if .Moderators}} + + + {{range .Moderators}} + + + + + {{end}} + +
+ {{template "avatar-16x16" .}} + {{.Username}} + + + + +
+ {{end}} + + + Appoint a moderator + +

+ You may appoint other members from the {{PrettyTitle}} community to help you moderate your forum. +

+
+ {{end}} + +
+
+ + {{if or (eq .Forum.OwnerID .CurrentUser.ID) (.CurrentUser.HasAdminScope "admin.forum.manage")}} + + + + {{end}} + {{if or .CurrentUser.IsAdmin (not .Forum.Privileged) (eq .Forum.OwnerID .CurrentUser.ID)}} @@ -83,6 +90,20 @@ {{SimplePager .Pager}}
+ +{{if not .Threads}} + +{{end}} + {{$Root := .}}
{{range .Threads}} @@ -172,4 +193,63 @@ {{SimplePager .Pager}}
+ +
+
+ + +
+ Created: {{.Forum.CreatedAt.Format "Jan _2 2006"}} +
+ {{if .Forum.Explicit}} + + + Explicit + + {{end}} + + {{if .Forum.Privileged}} + + + Privileged + + {{end}} + + {{if .Forum.PermitPhotos}} + + + Photos + + {{end}} + + {{if .Forum.Private}} + + + Private + + {{end}} +
+
+ + + + + {{template "avatar-16x16" .Forum.Owner}} + + {{.Forum.Owner.Username}} + + (owner) + + + {{range .ForumModerators}} + + {{template "avatar-16x16" .}} + + {{.Username}} + + + {{end}} +
+
+ {{end}} diff --git a/web/templates/forum/index.html b/web/templates/forum/index.html index b0b1e3c..aa35f58 100644 --- a/web/templates/forum/index.html +++ b/web/templates/forum/index.html @@ -32,8 +32,8 @@ {{template "ForumTabs" .}} - -{{if .IsBrowseTab}} + +{{if .IsExploreTab}}
@@ -251,8 +251,8 @@
{{end}} - -{{if .IsBrowseTab}} + +{{if .IsExploreTab}}
{{SimplePager .Pager}}
diff --git a/web/templates/forum/moderators.html b/web/templates/forum/moderators.html new file mode 100644 index 0000000..7df7ec9 --- /dev/null +++ b/web/templates/forum/moderators.html @@ -0,0 +1,138 @@ +{{define "title"}}Manage Forum Moderators{{end}} +{{define "content"}} +
+
+
+
+

+ Manage Forum Moderators +

+

+ For {{.Forum.Title}} /f/{{.Forum.Fragment}} +

+
+
+
+ +
+
+
+ +
+
+

+ + + {{if .IsRemoving}} + Remove a Moderator + {{else}} + Appoint a Moderator + {{end}} + +

+
+
+ + + {{if not .User}} +
+ You may use this page to appoint a moderator to help you + manage your forum. This can be anybody from the {{PrettyTitle}} community. +
+ +
+ Moderators will be able to delete threads and replies on your forum. +
+ +
+ As the owner of the forum, you retain full control over its settings and you alone + can add or remove its moderators. +
+ {{else}} +
+ {{if .IsRemoving}} + Confirm that you wish to remove moderator rights for + {{.User.Username}} on your forum. + {{else}} + Confirm that you wish to grant {{.User.Username}} + access to moderate your forum by clicking the button below. + {{end}} +
+
+
+ {{template "avatar-64x64" .User}} +
+
+

{{.User.NameOrUsername}}

+

+ + {{.User.Username}} +

+
+
+ {{end}} + + + + {{InputCSRF}} + + + {{if .User}} + + {{else}} +
+ + +
+ {{end}} + +
+ {{if .IsRemoving}} + + {{else}} + + {{end}} +
+ + +
+
+ +
+
+
+ +
+ + +{{end}} \ No newline at end of file diff --git a/web/templates/forum/newest.html b/web/templates/forum/newest.html index 02a5f82..58b04c3 100644 --- a/web/templates/forum/newest.html +++ b/web/templates/forum/newest.html @@ -19,19 +19,71 @@ {{template "ForumTabs" .}} -
- Found {{FormatNumberCommas .Pager.Total}} {{if .AllComments}}posts{{else}}threads{{end}} (page {{.Pager.Page}} of {{.Pager.Pages}}) + +
+
+
+
+ Found {{FormatNumberCommas .Pager.Total}} {{if .AllComments}}posts{{else}}threads{{end}} (page {{.Pager.Page}} of {{.Pager.Pages}}) +
+
-
- {{if not .AllComments}} - - Showing only the latest comment per thread. Show all comments instead? - {{else}} - Showing all forum posts by most recent. Deduplicate by thread? - {{end}} +
+
+
+ + {{if .FeatureUserForumsEnabled}} +
+ +
+ +
+
+ {{end}} + +
+ +
+ +
+
+
+
+
+
+ {{if not .AllComments}} + + Showing only the latest comment per thread. Show all comments instead? + {{else}} + Showing all forum posts by most recent. Deduplicate by thread? + {{end}} +
+
{{SimplePager .Pager}}
diff --git a/web/templates/forum/thread.html b/web/templates/forum/thread.html index 1f19e7c..ffafbf1 100644 --- a/web/templates/forum/thread.html +++ b/web/templates/forum/thread.html @@ -4,10 +4,39 @@
-

- - {{.Forum.Title}} -

+
+
+

+ + {{.Forum.Title}} +

+
+ + {{if .FeatureUserForumsEnabled}} +
+ +
+ {{InputCSRF}} + + + {{if .IsForumSubscribed}} + + {{else}} + + {{end}} +
+
+ {{end}} +
diff --git a/web/templates/partials/forum_tabs.html b/web/templates/partials/forum_tabs.html index 06b97e8..fea64f9 100644 --- a/web/templates/partials/forum_tabs.html +++ b/web/templates/partials/forum_tabs.html @@ -16,9 +16,9 @@ Variables that your template should set: Categories -
  • - - Browse +
  • + + Explore
  • diff --git a/web/templates/partials/user_avatar.html b/web/templates/partials/user_avatar.html index 2f97315..cfdc650 100644 --- a/web/templates/partials/user_avatar.html +++ b/web/templates/partials/user_avatar.html @@ -1,5 +1,24 @@ + +{{define "avatar-16x16"}} +
    + + {{if .ProfilePhoto.ID}} + {{if and (eq .ProfilePhoto.Visibility "private") (not .UserRelationship.IsPrivateGranted)}} + + {{else if and (eq .ProfilePhoto.Visibility "friends") (not .UserRelationship.IsFriend)}} + + {{else}} + + {{end}} + {{else}} + + {{end}} + +
    +{{end}} + {{define "avatar-24x24"}}