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:
parent
ef95d05453
commit
1bf846e78b
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -52,6 +52,7 @@ func Newest() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
|
"CurrentForumTab": "newest",
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
"RecentPosts": posts,
|
"RecentPosts": posts,
|
||||||
"PhotoMap": photos,
|
"PhotoMap": photos,
|
||||||
|
|
|
@ -100,6 +100,7 @@ func Search() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
|
"CurrentForumTab": "search",
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
"Comments": posts,
|
"Comments": posts,
|
||||||
"ThreadMap": threadMap,
|
"ThreadMap": threadMap,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()))
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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"> </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}}).
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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}})
|
||||||
|
|
|
@ -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">
|
||||||
|
|
32
web/templates/partials/forum_tabs.html
Normal file
32
web/templates/partials/forum_tabs.html
Normal 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}}
|
Loading…
Reference in New Issue
Block a user