Forum Admin Page for Regular Users

Allow regular (non-admin) users access to the Manage Forums page so they can
create and manage their own forums.

Things that were already working:

* The admin forum page was already anticipating regular LoginRequired credential
* Users only see their owned forums, while admins can see and manage ALL forums

Improvements made to the Forum Admin page:

* Change the title color from admin-red to user-blue.
* Add ability to search (filter) and sort the forums.

Other changes:

* Turn the Forum tab bar into a reusable component.
This commit is contained in:
Noah Petherbridge 2024-08-20 19:31:56 -07:00
parent ef95d05453
commit 1bf846e78b
13 changed files with 183 additions and 90 deletions

View File

@ -122,6 +122,9 @@ const (
// rapidly it does not increment the view counter more. // rapidly it does not increment the view counter more.
ThreadViewDebounceRedisKey = "debounce-view/user=%d/thr=%d" ThreadViewDebounceRedisKey = "debounce-view/user=%d/thr=%d"
ThreadViewDebounceCooldown = 1 * time.Hour ThreadViewDebounceCooldown = 1 * time.Hour
// Enable user-owned forums (feature flag)
UserForumsEnabled = true
) )
// Poll settings // Poll settings

View File

@ -12,7 +12,31 @@ import (
// Manage page for forums -- admin only for now but may open up later. // Manage page for forums -- admin only for now but may open up later.
func Manage() http.HandlerFunc { func Manage() http.HandlerFunc {
tmpl := templates.Must("forum/admin.html") tmpl := templates.Must("forum/admin.html")
// Whitelist for ordering options.
var sortWhitelist = []string{
"updated_at desc",
"created_at desc",
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
searchTerm = r.FormValue("q")
sort = r.FormValue("sort")
sortOK bool
)
// Sort options.
for _, v := range sortWhitelist {
if sort == v {
sortOK = true
break
}
}
if !sortOK {
sort = sortWhitelist[0]
}
// Get the current user. // Get the current user.
currentUser, err := session.CurrentUser(r) currentUser, err := session.CurrentUser(r)
if err != nil { if err != nil {
@ -21,15 +45,18 @@ func Manage() http.HandlerFunc {
return return
} }
// Parse their search term.
var search = models.ParseSearchString(searchTerm)
// Get forums the user owns or can manage. // Get forums the user owns or can manage.
var pager = &models.Pagination{ var pager = &models.Pagination{
Page: 1, Page: 1,
PerPage: config.PageSizeForumAdmin, PerPage: config.PageSizeForumAdmin,
Sort: "updated_at desc", Sort: sort,
} }
pager.ParsePage(r) pager.ParsePage(r)
forums, err := models.PaginateOwnedForums(currentUser.ID, currentUser.IsAdmin, pager) forums, err := models.PaginateOwnedForums(currentUser.ID, currentUser.IsAdmin, search, pager)
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't paginate owned forums: %s", err) session.FlashError(w, r, "Couldn't paginate owned forums: %s", err)
templates.Redirect(w, "/") templates.Redirect(w, "/")
@ -39,6 +66,10 @@ func Manage() http.HandlerFunc {
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"Pager": pager, "Pager": pager,
"Forums": forums, "Forums": forums,
// Search filters.
"SearchTerm": searchTerm,
"Sort": sort,
} }
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)

View File

@ -52,10 +52,11 @@ func Newest() http.HandlerFunc {
} }
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"Pager": pager, "CurrentForumTab": "newest",
"RecentPosts": posts, "Pager": pager,
"PhotoMap": photos, "RecentPosts": posts,
"AllComments": allComments, "PhotoMap": photos,
"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)

View File

@ -100,10 +100,11 @@ func Search() http.HandlerFunc {
} }
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"Pager": pager, "CurrentForumTab": "search",
"Comments": posts, "Pager": pager,
"ThreadMap": threadMap, "Comments": posts,
"PhotoMap": photos, "ThreadMap": threadMap,
"PhotoMap": photos,
"SearchTerm": searchTerm, "SearchTerm": searchTerm,
"ByUsername": byUsername, "ByUsername": byUsername,

View File

@ -116,20 +116,39 @@ func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Foru
} }
// PaginateOwnedForums returns forums the user owns (or all forums to admins). // PaginateOwnedForums returns forums the user owns (or all forums to admins).
func PaginateOwnedForums(userID uint64, isAdmin bool, pager *Pagination) ([]*Forum, error) { func PaginateOwnedForums(userID uint64, isAdmin bool, search *Search, pager *Pagination) ([]*Forum, error) {
var ( var (
fs = []*Forum{} fs = []*Forum{}
query = (&Forum{}).Preload() query = (&Forum{}).Preload()
wheres = []string{}
placeholders = []interface{}{}
) )
// Users see only their owned forums.
if !isAdmin { if !isAdmin {
query = query.Where( wheres = append(wheres, "owner_id = ?")
"owner_id = ?", placeholders = append(placeholders, userID)
userID,
)
} }
query = query.Order(pager.Sort) // Apply their search terms.
if search != nil {
for _, term := range search.Includes {
var ilike = "%" + strings.ToLower(term) + "%"
wheres = append(wheres, "(fragment ILIKE ? OR title ILIKE ? OR description ILIKE ?)")
placeholders = append(placeholders, ilike, ilike, ilike)
}
for _, term := range search.Excludes {
var ilike = "%" + strings.ToLower(term) + "%"
wheres = append(wheres, "(fragment NOT ILIKE ? AND title NOT ILIKE ? AND description NOT ILIKE ?)")
placeholders = append(placeholders, ilike, ilike, ilike)
}
}
query = query.Where(
strings.Join(wheres, " AND "),
placeholders...,
).Order(pager.Sort)
query.Model(&Forum{}).Count(&pager.Total) query.Model(&Forum{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs) result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
return fs, result.Error return fs, result.Error

View File

@ -100,8 +100,8 @@ 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.AdminRequired(config.ScopeForumAdmin, forum.Manage())) mux.Handle("/forum/admin", middleware.CertRequired(forum.Manage()))
mux.Handle("/forum/admin/edit", middleware.AdminRequired(config.ScopeForumAdmin, forum.AddEdit())) 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()))

View File

@ -23,6 +23,9 @@ func MergeVars(r *http.Request, m map[string]interface{}) {
// Integrations // Integrations
m["TurnstileCAPTCHA"] = config.Current.Turnstile m["TurnstileCAPTCHA"] = config.Current.Turnstile
// Feature flags
m["FeatureUserForumsEnabled"] = config.UserForumsEnabled
if r == nil { if r == nil {
return return
} }

View File

@ -167,6 +167,7 @@ var baseTemplates = []string{
config.TemplatePath + "/partials/right_click.html", config.TemplatePath + "/partials/right_click.html",
config.TemplatePath + "/partials/mark_explicit.html", config.TemplatePath + "/partials/mark_explicit.html",
config.TemplatePath + "/partials/themes.html", config.TemplatePath + "/partials/themes.html",
config.TemplatePath + "/partials/forum_tabs.html",
} }
// templates returns a template chain with the base templates preceding yours. // templates returns a template chain with the base templates preceding yours.

View File

@ -1,11 +1,11 @@
{{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-info is-bold">
<div class="hero-body"> <div class="hero-body">
<div class="container"> <div class="container">
<h1 class="title"> <h1 class="title">
<span class="icon mr-4"><i class="fa fa-peace"></i></span> <span class="icon mr-4"><i class="fa fa-book"></i></span>
<span>Forum Administration</span> <span>Forum Administration</span>
</h1> </h1>
</div> </div>
@ -22,6 +22,65 @@
</a> </a>
</div> </div>
<div class="block p-2">
<form action="{{.Request.URL.Path}}" method="GET">
<div class="card nonshy-collapsible-mobile">
<header class="card-header has-background-link-light">
<p class="card-header-title has-text-dark">
Search Filters
</p>
<button class="card-header-icon" type="button">
<span class="icon">
<i class="fa fa-angle-up"></i>
</span>
</button>
</header>
<div class="card-content">
<div class="columns">
<div class="column pr-1">
<div class="field">
<label class="label" for="q">Search terms:</label>
<input type="text" class="input"
name="q" id="q"
autocomplete="off"
value="{{.SearchTerm}}">
<p class="help">
Tip: you can <span class="has-text-success">"quote exact phrases"</span> and
<span class="has-text-success">-exclude</span> words (or
<span class="has-text-success">-"exclude phrases"</span>) from your search.
</p>
</div>
</div>
<div class="column px-1">
<div class="field">
<label class="label" for="sort">Sort by:</label>
<div class="select is-fullwidth">
<select id="sort" name="sort">
<option value="updated_at desc"{{if eq .Sort "updated_at desc"}} selected{{end}}>Last updated</option>
<option value="created_at desc"{{if eq .Sort "created_at desc"}} selected{{end}}>Newest created</option>
</select>
</div>
</div>
</div>
<div class="column is-narrow pl-1 has-text-right">
<label class="label">&nbsp;</label>
<a href="{{.Request.URL.Path}}" class="button">Reset</a>
<button type="submit" class="button is-success">
<span>Search</span>
<span class="icon"><i class="fa fa-search"></i></span>
</button>
</div>
</div>
</div>
</div>
</form>
</div>
<p class="block p-2"> <p class="block p-2">
Found <strong>{{.Pager.Total}}</strong> forum{{Pluralize64 .Pager.Total}} you can manage Found <strong>{{.Pager.Total}}</strong> forum{{Pluralize64 .Pager.Total}} you can manage
(page {{.Pager.Page}} of {{.Pager.Pages}}). (page {{.Pager.Page}} of {{.Pager.Pages}}).

View File

@ -5,18 +5,18 @@
<div class="hero-body"> <div class="hero-body">
<div class="container"> <div class="container">
<div class="level"> <div class="level">
<div class="level-left"> <div class="level-left mb-4">
<h1 class="title"> <h1 class="title">
<span class="icon mr-4"><i class="fa fa-comments"></i></span> <span class="icon mr-4"><i class="fa fa-comments"></i></span>
<span>Forums</span> <span>Forums</span>
</h1> </h1>
</div> </div>
{{if .CurrentUser.HasAdminScope "admin.forum.manage"}} {{if or .FeatureUserForumsEnabled (.CurrentUser.HasAdminScope "admin.forum.manage")}}
<div class="level-right"> <div class="level-right">
<div> <div>
<a href="/forum/admin" class="button is-small has-text-danger"> <a href="/forum/admin" class="button is-small">
<span class="icon"><i class="fa fa-peace"></i></span> <span class="icon"><i class="fa fa-circle-plus"></i></span>
<span>Manage Forums</span> <span>Create a forum</span>
</a> </a>
</div> </div>
</div> </div>
@ -29,27 +29,8 @@
{{$Root := .}} {{$Root := .}}
<div class="block p-4 mb-0"> <!-- Tab bar -->
<div class="tabs is-boxed"> {{template "ForumTabs" .}}
<ul>
<li class="is-active">
<a href="/forum">
Categories
</a>
</li>
<li>
<a href="/forum/newest">
Newest
</a>
</li>
<li>
<a href="/forum/search">
<i class="fa fa-search mr-2"></i> Search
</a>
</li>
</ul>
</div>
</div>
{{range .Categories}} {{range .Categories}}
<div class="block p-4"> <div class="block p-4">

View File

@ -16,27 +16,8 @@
{{$Root := .}} {{$Root := .}}
<div class="block p-4 mb-0"> <!-- Tab bar -->
<div class="tabs is-boxed"> {{template "ForumTabs" .}}
<ul>
<li>
<a href="/forum">
Categories
</a>
</li>
<li class="is-active">
<a href="/forum/newest">
Newest
</a>
</li>
<li>
<a href="/forum/search">
<i class="fa fa-search mr-2"></i> Search
</a>
</li>
</ul>
</div>
</div>
<div class="p-4"> <div class="p-4">
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}})

View File

@ -16,27 +16,8 @@
{{$Root := .}} {{$Root := .}}
<div class="block p-4 mb-0"> <!-- Tab bar -->
<div class="tabs is-boxed"> {{template "ForumTabs" .}}
<ul>
<li>
<a href="/forum">
Categories
</a>
</li>
<li>
<a href="/forum/newest">
Newest
</a>
</li>
<li class="is-active">
<a href="/forum/search">
<i class="fa fa-search mr-2"></i> Search
</a>
</li>
</ul>
</div>
</div>
<!-- Search fields --> <!-- Search fields -->
<div class="p-4"> <div class="p-4">

View File

@ -0,0 +1,32 @@
<!--
Reusable Forums tab bar.
Usage: [[ForumTabs .]]
Variables that your template should set:
- CurrentForumTab (string): one of categories, newest, search.
-->
{{define "ForumTabs"}}
<div class="block p-4 mb-0">
<div class="tabs is-boxed">
<ul>
<li {{if or (eq .CurrentForumTab "categories") (not .CurrentForumTab) }}class="is-active"{{end}}>
<a href="/forum">
Categories
</a>
</li>
<li {{if eq .CurrentForumTab "newest" }}class="is-active"{{end}}>
<a href="/forum/newest">
Newest
</a>
</li>
<li {{if eq .CurrentForumTab "search" }}class="is-active"{{end}}>
<a href="/forum/search">
<i class="fa fa-search mr-2"></i> Search
</a>
</li>
</ul>
</div>
</div>
{{end}}