Forum Memberships & My List
* Add "Browse" tab to the forums to view them all. * Text search * Show all, official, community, or "My List" forums. * Add a Follow/Unfollow button into the header bar of forums to add it to "My List" * On the Categories page, a special "My List" category appears at the top if the user follows categories, with their follows in alphabetical order. * On the Categories & Browse pages: forums you follow will have a green bookmark icon by their name. Permissions: * The forum owner is able to Delete comments by others, but not Edit. Notes: * Currently a max limit of 100 follow forums (no pagination yet).
This commit is contained in:
parent
27540534ad
commit
eb85b2e090
|
@ -20,8 +20,9 @@ var (
|
||||||
PageSizeAdminUserNotes = 10 // other users' notes
|
PageSizeAdminUserNotes = 10 // other users' notes
|
||||||
PageSizeSiteGallery = 16
|
PageSizeSiteGallery = 16
|
||||||
PageSizeUserGallery = 16
|
PageSizeUserGallery = 16
|
||||||
PageSizeInboxList = 20 // sidebar list
|
PageSizeInboxList = 20 // sidebar list
|
||||||
PageSizeInboxThread = 10 // conversation view
|
PageSizeInboxThread = 10 // conversation view
|
||||||
|
PageSizeBrowseForums = 20
|
||||||
PageSizeForums = 100 // TODO: for main category index view
|
PageSizeForums = 100 // TODO: for main category index view
|
||||||
PageSizeThreadList = 20 // 20 threads per board, 20 posts per thread
|
PageSizeThreadList = 20 // 20 threads per board, 20 posts per thread
|
||||||
PageSizeForumAdmin = 20
|
PageSizeForumAdmin = 20
|
||||||
|
|
104
pkg/controller/forum/browse.go
Normal file
104
pkg/controller/forum/browse.go
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
package forum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Browse all existing forums.
|
||||||
|
func Browse() http.HandlerFunc {
|
||||||
|
// This page shares a template with the board index (Categories) page.
|
||||||
|
tmpl := templates.Must("forum/index.html")
|
||||||
|
|
||||||
|
// Whitelist for ordering options.
|
||||||
|
var sortWhitelist = []string{
|
||||||
|
"title asc",
|
||||||
|
"title desc",
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
searchTerm = r.FormValue("q")
|
||||||
|
search = models.ParseSearchString(searchTerm)
|
||||||
|
|
||||||
|
show = r.FormValue("show")
|
||||||
|
categories = []string{}
|
||||||
|
|
||||||
|
subscribed = r.FormValue("show") == "followed"
|
||||||
|
|
||||||
|
sort = r.FormValue("sort")
|
||||||
|
sortOK bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sort options.
|
||||||
|
for _, v := range sortWhitelist {
|
||||||
|
if sort == v {
|
||||||
|
sortOK = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sortOK {
|
||||||
|
sort = sortWhitelist[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set of forum categories to filter for.
|
||||||
|
switch show {
|
||||||
|
case "official":
|
||||||
|
categories = config.ForumCategories
|
||||||
|
case "community":
|
||||||
|
categories = []string{""}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current user.
|
||||||
|
currentUser, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't get current user: %s", err)
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var pager = &models.Pagination{
|
||||||
|
Page: 1,
|
||||||
|
PerPage: config.PageSizeBrowseForums,
|
||||||
|
Sort: sort,
|
||||||
|
}
|
||||||
|
pager.ParsePage(r)
|
||||||
|
|
||||||
|
// Browse all forums (no category filter for official)
|
||||||
|
forums, err := models.PaginateForums(currentUser, categories, search, subscribed, pager)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bucket the forums into their categories for easy front-end.
|
||||||
|
categorized := models.CategorizeForums(forums, nil)
|
||||||
|
|
||||||
|
// Map statistics for these forums.
|
||||||
|
forumMap := models.MapForumStatistics(forums)
|
||||||
|
followMap := models.MapForumMemberships(currentUser, forums)
|
||||||
|
|
||||||
|
var vars = map[string]interface{}{
|
||||||
|
"CurrentForumTab": "browse",
|
||||||
|
"IsBrowseTab": true,
|
||||||
|
"Pager": pager,
|
||||||
|
"Categories": categorized,
|
||||||
|
"ForumMap": forumMap,
|
||||||
|
"FollowMap": followMap,
|
||||||
|
|
||||||
|
// Search filters
|
||||||
|
"SearchTerm": searchTerm,
|
||||||
|
"Show": show,
|
||||||
|
"Sort": sort,
|
||||||
|
}
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -72,10 +72,11 @@ func Forum() http.HandlerFunc {
|
||||||
threadMap := models.MapThreadStatistics(threads)
|
threadMap := models.MapThreadStatistics(threads)
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"Forum": forum,
|
"Forum": forum,
|
||||||
"Threads": threads,
|
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
|
||||||
"ThreadMap": threadMap,
|
"Threads": threads,
|
||||||
"Pager": pager,
|
"ThreadMap": threadMap,
|
||||||
|
"Pager": pager,
|
||||||
}
|
}
|
||||||
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)
|
||||||
|
|
|
@ -40,7 +40,7 @@ func Landing() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
pager.ParsePage(r)
|
pager.ParsePage(r)
|
||||||
|
|
||||||
forums, err := models.PaginateForums(currentUser, config.ForumCategories, pager)
|
forums, err := models.PaginateForums(currentUser, config.ForumCategories, nil, false, pager)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
|
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
|
||||||
templates.Redirect(w, "/")
|
templates.Redirect(w, "/")
|
||||||
|
@ -50,13 +50,29 @@ func Landing() http.HandlerFunc {
|
||||||
// Bucket the forums into their categories for easy front-end.
|
// Bucket the forums into their categories for easy front-end.
|
||||||
categorized := models.CategorizeForums(forums, config.ForumCategories)
|
categorized := models.CategorizeForums(forums, config.ForumCategories)
|
||||||
|
|
||||||
|
// Inject the "My List" Category if the user subscribes to forums.
|
||||||
|
myList, err := models.PaginateForums(currentUser, nil, nil, true, pager)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't get your followed forums: %s", err)
|
||||||
|
} else {
|
||||||
|
forums = append(forums, myList...)
|
||||||
|
categorized = append([]*models.CategorizedForum{
|
||||||
|
{
|
||||||
|
Category: "My List",
|
||||||
|
Forums: myList,
|
||||||
|
},
|
||||||
|
}, categorized...)
|
||||||
|
}
|
||||||
|
|
||||||
// Map statistics for these forums.
|
// Map statistics for these forums.
|
||||||
forumMap := models.MapForumStatistics(forums)
|
forumMap := models.MapForumStatistics(forums)
|
||||||
|
followMap := models.MapForumMemberships(currentUser, forums)
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
"Categories": categorized,
|
"Categories": categorized,
|
||||||
"ForumMap": forumMap,
|
"ForumMap": forumMap,
|
||||||
|
"FollowMap": followMap,
|
||||||
}
|
}
|
||||||
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)
|
||||||
|
|
|
@ -87,6 +87,11 @@ func NewPost() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the current user can moderate the forum thread, e.g. edit or delete posts.
|
||||||
|
// Admins can edit always, user owners of forums can only delete.
|
||||||
|
var canModerate = currentUser.HasAdminScope(config.ScopeForumModerator) ||
|
||||||
|
(forum.OwnerID == currentUser.ID && isDelete)
|
||||||
|
|
||||||
// Does the comment have an existing Photo ID?
|
// Does the comment have an existing Photo ID?
|
||||||
if len(photoID) > 0 {
|
if len(photoID) > 0 {
|
||||||
if i, err := strconv.Atoi(photoID); err == nil {
|
if i, err := strconv.Atoi(photoID); err == nil {
|
||||||
|
@ -116,7 +121,7 @@ func NewPost() http.HandlerFunc {
|
||||||
comment = found
|
comment = found
|
||||||
|
|
||||||
// Verify that it is indeed OUR comment.
|
// Verify that it is indeed OUR comment.
|
||||||
if currentUser.ID != comment.UserID && !currentUser.HasAdminScope(config.ScopeForumModerator) {
|
if currentUser.ID != comment.UserID && !canModerate {
|
||||||
templates.ForbiddenPage(w, r)
|
templates.ForbiddenPage(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
67
pkg/controller/forum/subscribe.go
Normal file
67
pkg/controller/forum/subscribe.go
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
package forum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscribe to a forum, adding it to your bookmark list.
|
||||||
|
func Subscribe() http.HandlerFunc {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Parse the path parameters
|
||||||
|
var (
|
||||||
|
fragment = r.FormValue("fragment")
|
||||||
|
forum *models.Forum
|
||||||
|
intent = r.FormValue("intent")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Look up the forum by its fragment.
|
||||||
|
if found, err := models.ForumByFragment(fragment); err != nil {
|
||||||
|
templates.NotFoundPage(w, r)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
forum = found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current user.
|
||||||
|
currentUser, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't get current user: %s", err)
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is it a private forum?
|
||||||
|
if forum.Private && !currentUser.IsAdmin {
|
||||||
|
templates.NotFoundPage(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch intent {
|
||||||
|
case "follow":
|
||||||
|
_, err := models.CreateForumMembership(currentUser, forum)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't follow this forum: %s", err)
|
||||||
|
} else {
|
||||||
|
session.Flash(w, r, "You have added %s to your forum list.", forum.Title)
|
||||||
|
}
|
||||||
|
case "unfollow":
|
||||||
|
fm, err := models.GetForumMembership(currentUser, forum)
|
||||||
|
if err == nil {
|
||||||
|
err = fm.Delete()
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't delete your forum membership: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Flash(w, r, "You have removed %s from your forum list.", forum.Title)
|
||||||
|
default:
|
||||||
|
session.Flash(w, r, "Unknown intent.")
|
||||||
|
}
|
||||||
|
|
||||||
|
templates.Redirect(w, "/f/"+fragment)
|
||||||
|
})
|
||||||
|
}
|
|
@ -57,6 +57,13 @@ func Thread() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Can we moderate this forum? (from a user-owned forum perspective,
|
||||||
|
// e.g. can we delete threads and posts, not edit them)
|
||||||
|
var canModerate bool
|
||||||
|
if currentUser.HasAdminScope(config.ScopeForumModerator) || forum.OwnerID == currentUser.ID {
|
||||||
|
canModerate = true
|
||||||
|
}
|
||||||
|
|
||||||
// Ping the view count on this thread.
|
// Ping the view count on this thread.
|
||||||
if err := thread.View(currentUser.ID); err != nil {
|
if err := thread.View(currentUser.ID); err != nil {
|
||||||
log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err)
|
log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err)
|
||||||
|
@ -100,6 +107,7 @@ func Thread() http.HandlerFunc {
|
||||||
"LikeMap": commentLikeMap,
|
"LikeMap": commentLikeMap,
|
||||||
"PhotoMap": photos,
|
"PhotoMap": photos,
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
|
"CanModerate": canModerate,
|
||||||
"IsSubscribed": isSubscribed,
|
"IsSubscribed": isSubscribed,
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
|
|
@ -78,7 +78,7 @@ Parameters:
|
||||||
- categories: optional, filter within categories
|
- categories: optional, filter within categories
|
||||||
- pager
|
- pager
|
||||||
*/
|
*/
|
||||||
func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Forum, error) {
|
func PaginateForums(user *User, categories []string, search *Search, subscribed bool, pager *Pagination) ([]*Forum, error) {
|
||||||
var (
|
var (
|
||||||
fs = []*Forum{}
|
fs = []*Forum{}
|
||||||
query = (&Forum{}).Preload()
|
query = (&Forum{}).Preload()
|
||||||
|
@ -101,6 +101,33 @@ func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Foru
|
||||||
wheres = append(wheres, "private is not true")
|
wheres = append(wheres, "private is not true")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Followed forums only? (for the My List category on home page)
|
||||||
|
if subscribed {
|
||||||
|
wheres = append(wheres, `
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM forum_memberships
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND forum_id = forums.id
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
placeholders = append(placeholders, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Filters?
|
// Filters?
|
||||||
if len(wheres) > 0 {
|
if len(wheres) > 0 {
|
||||||
query = query.Where(
|
query = query.Where(
|
||||||
|
@ -178,6 +205,15 @@ func CategorizeForums(fs []*Forum, categories []string) []*CategorizedForum {
|
||||||
idxMap = map[string]int{}
|
idxMap = map[string]int{}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Forum Browse page: we are not grouping by categories but still need at least one.
|
||||||
|
if len(categories) == 0 {
|
||||||
|
return []*CategorizedForum{
|
||||||
|
{
|
||||||
|
Forums: fs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize the result set.
|
// Initialize the result set.
|
||||||
for i, category := range categories {
|
for i, category := range categories {
|
||||||
result = append(result, &CategorizedForum{
|
result = append(result, &CategorizedForum{
|
||||||
|
|
121
pkg/models/forum_membership.go
Normal file
121
pkg/models/forum_membership.go
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ForumMembership table.
|
||||||
|
type ForumMembership struct {
|
||||||
|
ID uint64 `gorm:"primaryKey"`
|
||||||
|
UserID uint64 `gorm:"index"`
|
||||||
|
User User `gorm:"foreignKey:user_id"`
|
||||||
|
ForumID uint64 `gorm:"index"`
|
||||||
|
Forum Forum `gorm:"foreignKey:forum_id"`
|
||||||
|
Approved bool `gorm:"index"`
|
||||||
|
IsModerator bool `gorm:"index"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload related tables for the forum (classmethod).
|
||||||
|
func (f *ForumMembership) Preload() *gorm.DB {
|
||||||
|
return DB.Preload("User").Preload("Forum")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateForumMembership subscribes the user to a forum.
|
||||||
|
func CreateForumMembership(user *User, forum *Forum) (*ForumMembership, error) {
|
||||||
|
var (
|
||||||
|
f = &ForumMembership{
|
||||||
|
User: *user,
|
||||||
|
Forum: *forum,
|
||||||
|
Approved: true,
|
||||||
|
}
|
||||||
|
result = DB.Create(f)
|
||||||
|
)
|
||||||
|
return f, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetForumMembership looks up a forum membership.
|
||||||
|
func GetForumMembership(user *User, forum *Forum) (*ForumMembership, error) {
|
||||||
|
var (
|
||||||
|
f = &ForumMembership{}
|
||||||
|
result = f.Preload().Where(
|
||||||
|
"user_id = ? AND forum_id = ?",
|
||||||
|
user.ID, forum.ID,
|
||||||
|
).First(&f)
|
||||||
|
)
|
||||||
|
return f, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a forum membership.
|
||||||
|
func (f *ForumMembership) Delete() error {
|
||||||
|
return DB.Delete(f).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaginateForumMemberships paginates over a user's ForumMemberships.
|
||||||
|
func PaginateForumMemberships(user *User, pager *Pagination) ([]*ForumMembership, error) {
|
||||||
|
var (
|
||||||
|
fs = []*ForumMembership{}
|
||||||
|
query = (&ForumMembership{}).Preload()
|
||||||
|
wheres = []string{}
|
||||||
|
placeholders = []interface{}{}
|
||||||
|
)
|
||||||
|
|
||||||
|
query = query.Where(
|
||||||
|
strings.Join(wheres, " AND "),
|
||||||
|
placeholders...,
|
||||||
|
).Order(pager.Sort)
|
||||||
|
|
||||||
|
query.Model(&ForumMembership{}).Count(&pager.Total)
|
||||||
|
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
|
||||||
|
return fs, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumMembershipMap maps table IDs to Likes metadata.
|
||||||
|
type ForumMembershipMap map[uint64]bool
|
||||||
|
|
||||||
|
// Get like stats from the map.
|
||||||
|
func (fm ForumMembershipMap) Get(id uint64) bool {
|
||||||
|
return fm[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapForumMemberships looks up a user's memberships in bulk.
|
||||||
|
func MapForumMemberships(user *User, forums []*Forum) ForumMembershipMap {
|
||||||
|
var (
|
||||||
|
result = ForumMembershipMap{}
|
||||||
|
forumIDs = []uint64{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize the result set.
|
||||||
|
for _, forum := range forums {
|
||||||
|
result[forum.ID] = false
|
||||||
|
forumIDs = append(forumIDs, forum.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the forum IDs the user subscribes to.
|
||||||
|
var followIDs = []uint64{}
|
||||||
|
if res := DB.Model(&ForumMembership{}).Select(
|
||||||
|
"forum_id",
|
||||||
|
).Where(
|
||||||
|
"user_id = ? AND forum_id IN ?",
|
||||||
|
user.ID, forumIDs,
|
||||||
|
).Scan(&followIDs); res.Error != nil {
|
||||||
|
log.Error("MapForumMemberships: %s", res.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, forumID := range followIDs {
|
||||||
|
result[forumID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
|
@ -35,4 +35,5 @@ func AutoMigrate() {
|
||||||
DB.AutoMigrate(&IPAddress{})
|
DB.AutoMigrate(&IPAddress{})
|
||||||
DB.AutoMigrate(&PushNotification{})
|
DB.AutoMigrate(&PushNotification{})
|
||||||
DB.AutoMigrate(&WorldCities{})
|
DB.AutoMigrate(&WorldCities{})
|
||||||
|
DB.AutoMigrate(&ForumMembership{})
|
||||||
}
|
}
|
||||||
|
|
|
@ -87,8 +87,10 @@ func New() http.Handler {
|
||||||
mux.Handle("GET /forum", middleware.CertRequired(forum.Landing()))
|
mux.Handle("GET /forum", middleware.CertRequired(forum.Landing()))
|
||||||
mux.Handle("/forum/post", middleware.CertRequired(forum.NewPost()))
|
mux.Handle("/forum/post", middleware.CertRequired(forum.NewPost()))
|
||||||
mux.Handle("GET /forum/thread/{id}", middleware.CertRequired(forum.Thread()))
|
mux.Handle("GET /forum/thread/{id}", middleware.CertRequired(forum.Thread()))
|
||||||
|
mux.Handle("GET /forum/browse", middleware.CertRequired(forum.Browse()))
|
||||||
mux.Handle("GET /forum/newest", middleware.CertRequired(forum.Newest()))
|
mux.Handle("GET /forum/newest", middleware.CertRequired(forum.Newest()))
|
||||||
mux.Handle("GET /forum/search", middleware.CertRequired(forum.Search()))
|
mux.Handle("GET /forum/search", middleware.CertRequired(forum.Search()))
|
||||||
|
mux.Handle("POST /forum/subscribe", middleware.CertRequired(forum.Subscribe()))
|
||||||
mux.Handle("GET /f/{fragment}", middleware.CertRequired(forum.Forum()))
|
mux.Handle("GET /f/{fragment}", middleware.CertRequired(forum.Forum()))
|
||||||
mux.Handle("POST /poll/vote", middleware.CertRequired(poll.Vote()))
|
mux.Handle("POST /poll/vote", middleware.CertRequired(poll.Vote()))
|
||||||
|
|
||||||
|
|
|
@ -4,10 +4,39 @@
|
||||||
<section class="hero is-light is-success">
|
<section class="hero is-light is-success">
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="title">
|
<div class="level">
|
||||||
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
|
<div class="level-left mb-4">
|
||||||
<span>{{.Forum.Title}}</span>
|
<h1 class="title">
|
||||||
</h1>
|
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
|
||||||
|
<span>{{.Forum.Title}}</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .FeatureUserForumsEnabled}}
|
||||||
|
<div class="level-right">
|
||||||
|
<!-- Follow/Unfollow This Forum -->
|
||||||
|
<form action="/forum/subscribe" method="POST" class="is-inline">
|
||||||
|
{{InputCSRF}}
|
||||||
|
<input type="hidden" name="fragment" value="{{.Forum.Fragment}}">
|
||||||
|
|
||||||
|
{{if .IsForumSubscribed}}
|
||||||
|
<button type="submit" class="button"
|
||||||
|
name="intent" value="unfollow"
|
||||||
|
onclick="return confirm('Do you want to remove this forum from your list?')">
|
||||||
|
<span class="icon"><i class="fa fa-bookmark"></i></span>
|
||||||
|
<span>Followed</span>
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
<button type="submit" class="button"
|
||||||
|
name="intent" value="follow">
|
||||||
|
<span class="icon"><i class="fa-regular fa-bookmark"></i></span>
|
||||||
|
<span>Follow</span>
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
@ -29,7 +58,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
{{if or .CurrentUser.IsAdmin (not .Forum.Privileged) (eq .Forum.OwnerID .CurrentUser.ID)}}
|
{{if or .CurrentUser.IsAdmin (not .Forum.Privileged) (eq .Forum.OwnerID .CurrentUser.ID)}}
|
||||||
<a href="/forum/post?to={{.Forum.Fragment}}" class="button is-primary">
|
<a href="/forum/post?to={{.Forum.Fragment}}" class="button is-info">
|
||||||
<span class="icon"><i class="fa fa-plus"></i></span>
|
<span class="icon"><i class="fa fa-plus"></i></span>
|
||||||
<span>New Thread</span>
|
<span>New Thread</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -32,9 +32,101 @@
|
||||||
<!-- Tab bar -->
|
<!-- Tab bar -->
|
||||||
{{template "ForumTabs" .}}
|
{{template "ForumTabs" .}}
|
||||||
|
|
||||||
|
<!-- Filters for the Browse tab -->
|
||||||
|
{{if .IsBrowseTab}}
|
||||||
|
<div class="block mb-0 p-4">
|
||||||
|
<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 is-half 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="show">Show:</label>
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
<select id="show" name="show">
|
||||||
|
<option value=""{{if eq .Show ""}} selected{{end}}>All forums</option>
|
||||||
|
<option value="official"{{if eq .Show "official"}} selected{{end}}>Official nonshy forums</option>
|
||||||
|
<option value="community"{{if eq .Show "community"}} selected{{end}}>Community forums only</option>
|
||||||
|
<option value="followed"{{if eq .Show "followed"}} selected{{end}}>My List</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</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="title asc"{{if eq .Sort "title asc"}} selected{{end}}>Title (A-Z)</option>
|
||||||
|
<option value="title desc"{{if eq .Sort "title desc"}} selected{{end}}>Title (Z-A)</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-4">
|
||||||
|
Found <strong>{{.Pager.Total}}</strong> forum{{Pluralize64 .Pager.Total}}.
|
||||||
|
(page {{.Pager.Page}} of {{.Pager.Pages}}).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="block p-4">
|
||||||
|
{{SimplePager .Pager}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{range .Categories}}
|
{{range .Categories}}
|
||||||
<div class="block p-4">
|
<div class="block p-4">
|
||||||
<h1 class="title">{{.Category}}</h1>
|
{{if .Category}}
|
||||||
|
<h1 class="title">
|
||||||
|
{{.Category}}
|
||||||
|
{{if eq .Category "My List"}}
|
||||||
|
<i class="fa fa-book-bookmark ml-2"></i>
|
||||||
|
{{end}}
|
||||||
|
</h1>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if eq (len .Forums) 0}}
|
{{if eq (len .Forums) 0}}
|
||||||
<em>
|
<em>
|
||||||
|
@ -50,6 +142,9 @@
|
||||||
<div class="column is-3 pt-0 pb-1">
|
<div class="column is-3 pt-0 pb-1">
|
||||||
|
|
||||||
<h2 class="is-size-4">
|
<h2 class="is-size-4">
|
||||||
|
{{if $Root.FollowMap.Get .ID}}
|
||||||
|
<sup class="fa fa-bookmark has-text-success is-size-6 mr-1" title="Followed"></sup>
|
||||||
|
{{end}}
|
||||||
<strong><a href="/f/{{.Fragment}}">{{.Title}}</a></strong>
|
<strong><a href="/f/{{.Fragment}}">{{.Title}}</a></strong>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
@ -156,4 +251,11 @@
|
||||||
</div>
|
</div>
|
||||||
{{end}}<!-- range .Categories -->
|
{{end}}<!-- range .Categories -->
|
||||||
|
|
||||||
|
<!-- Pager footer for Browse tab -->
|
||||||
|
{{if .IsBrowseTab}}
|
||||||
|
<div class="block p-4">
|
||||||
|
{{SimplePager .Pager}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{end}}
|
{{end}}
|
|
@ -289,6 +289,9 @@
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if or $Root.CanModerate ($Root.CurrentUser.HasAdminScope "social.moderator.forum") (eq $Root.CurrentUser.ID .User.ID)}}
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}&edit={{.ID}}&delete=true" onclick="return confirm('Are you sure you want to delete this comment?')" class="has-text-dark">
|
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}&edit={{.ID}}&delete=true" onclick="return confirm('Are you sure you want to delete this comment?')" class="has-text-dark">
|
||||||
<span class="icon"><i class="fa fa-trash"></i></span>
|
<span class="icon"><i class="fa fa-trash"></i></span>
|
||||||
|
|
|
@ -16,6 +16,11 @@ Variables that your template should set:
|
||||||
Categories
|
Categories
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li {{if eq .CurrentForumTab "browse" }}class="is-active"{{end}}>
|
||||||
|
<a href="/forum/browse">
|
||||||
|
Browse
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li {{if eq .CurrentForumTab "newest" }}class="is-active"{{end}}>
|
<li {{if eq .CurrentForumTab "newest" }}class="is-active"{{end}}>
|
||||||
<a href="/forum/newest">
|
<a href="/forum/newest">
|
||||||
Newest
|
Newest
|
||||||
|
|
Loading…
Reference in New Issue
Block a user